Compare commits

...
Author SHA1 Message Date
Aiden Cline 020b2240c2 docs: update SDK docs to reference v2 SDK exclusively
- Update all imports to @opencode-ai/sdk/v2
- Fix types URL to point to v2 generated types
- Fix file.read response type: text|binary (not raw|patch)
- Fix permission method name: permission.reply (not postSessionByIdPermissionsByPermissionId)
2026-04-30 23:22:11 -05:00
Aiden Cline 08895c396e docs: fix tui and keybinds documentation (#25233) 2026-04-30 23:20:54 -05:00
opencode-agent[bot] 4e451a4b0f chore: update nix node_modules hashes 2026-05-01 04:07:58 +00:00
Brendan AllanandJay V 163290bcf0 desktop: sentry integration (#15300)
Co-authored-by: Jay V <air@live.ca>
2026-05-01 11:56:31 +08:00
Aiden Cline c68c33d4fe docs: remove deprecated modes.mdx pages (#25227) 2026-04-30 22:49:32 -05:00
Dax Raad 3615d8e226 core: clarify that temp directory already exists for AI agents
The bash tool description now explicitly states that the temp directory has already been created and exists, preventing agents from unnecessarily trying to create it before use.
2026-04-30 23:48:48 -04:00
Dax 2283979199 Preapprove agent tmp directory access (#25226) 2026-04-30 23:47:15 -04:00
Aiden Cline 33f7f593ee fix: tui list jank issue (#25219) 2026-04-30 22:45:41 -05:00
opencode-agent[bot] 461e7345b3 chore: update nix node_modules hashes 2026-05-01 03:32:55 +00:00
opencode-agent[bot] 6bd91c68e8 chore: generate 2026-05-01 03:22:36 +00:00
Dax Raad ff55a40749 core: remove @effect/language-service plugin and optimize hot path type performance
- Removed @effect/language-service from both packages/core and packages/opencode tsconfig files and dependencies

- Wrapped mergeDeep calls in config loading and LLM streaming to avoid expensive remeda conditional merge type instantiations in hot paths

- Narrowed Drizzle migrate() overload signature to avoid expensive variance checks during database initialization

These changes reduce TypeScript type-checking overhead and improve startup and runtime performance for config loading, LLM streaming, and database migrations.
2026-04-30 23:21:05 -04:00
opencode-agent[bot] 8b56d77ea1 chore: generate 2026-05-01 03:02:15 +00:00
Kit Langton dd3aa96730 test(httpapi): cover more safe GET parity (#25217) 2026-04-30 23:01:11 -04:00
Kit Langton 8b56d1712f refactor(session): pass project to list (#25215) 2026-04-30 23:00:59 -04:00
Kit Langton 3c24d22d42 fix(httpapi): omit absent optional response fields (#25214) 2026-05-01 02:38:32 +00:00
Kit Langton 4c70ea28d2 fix(tui): scope Zed editor context to containing workspaces (#25211) 2026-04-30 22:33:39 -04:00
Kit Langton 5ba68a28c0 refactor(httpapi): scope async prompt fiber (#25213) 2026-04-30 22:33:02 -04:00
opencode-agent[bot] bce4def2db chore: generate 2026-05-01 02:26:56 +00:00
Kit Langton 3544ea0244 refactor(httpapi): drop session prompt bridge (#25210) 2026-04-30 22:25:52 -04:00
opencode-agent[bot] 6434918794 chore: generate 2026-05-01 01:46:55 +00:00
Kit Langton 5984d917dc refactor(session): yield instance context in system prompt (#25207) 2026-04-30 21:45:48 -04:00
Kit Langton c2a97a7a6c refactor(file): yield instance context in watcher (#25205) 2026-04-30 21:45:21 -04:00
Kit Langton a083c88e87 refactor(sync): capture instance context for publish (#25206) 2026-04-30 21:45:02 -04:00
Kit Langton ce3b0988c4 refactor(project): yield instance context in bootstrap (#25204) 2026-04-30 21:44:52 -04:00
Kit Langton e8a194a2bb test(effect): stabilize runner active shell check (#25203) 2026-04-30 21:36:19 -04:00
Kit Langton 8aa8798e07 refactor(session): yield instance context in llm (#25200) 2026-04-30 21:29:28 -04:00
opencode-agent[bot] 6d4629b566 chore: generate 2026-05-01 01:28:37 +00:00
OpeOginni a9d399699e fix(desktop): Prevent Model response Interruption when opening settings dialog (#25114) 2026-05-01 01:27:38 +00:00
Kit Langton bc805b3001 Pass CORS options to HttpApi backend (#25201) 2026-04-30 21:26:32 -04:00
Kit Langton 668d77bb4e refactor(tool): yield InstanceState context (#25199) 2026-04-30 21:01:06 -04:00
Kit Langton 5c2e06f353 Document HttpApi route patterns (#25188) 2026-04-30 20:48:14 -04:00
Kit Langton a499fe2b17 refactor(tool/read): yield InstanceState.context instead of reading ALS (#25183) 2026-04-30 20:33:04 -04:00
Kit Langton 451650b584 refactor(httpapi): preserve typed errors in session prompt handlers (#25181) 2026-04-30 20:04:00 -04:00
opencode-agent[bot] 1b76bec0e2 chore: generate 2026-05-01 00:03:55 +00:00
Kit Langton 96f4da1e1d Serve instance events through HttpApiBuilder (#25182) 2026-04-30 20:02:46 -04:00
opencode-agent[bot] 96a0dd6b04 chore: generate 2026-04-30 23:37:58 +00:00
Kit Langton 2dd1f2d453 Avoid request-time HttpApi layer provisioning (#25179) 2026-04-30 19:36:57 -04:00
opencode-agent[bot] 510f01674a chore: generate 2026-04-30 23:29:58 +00:00
Kit Langton e3134a2a99 refactor(session): align prompt input types with their schemas (#25178) 2026-04-30 19:28:46 -04:00
opencode-agent[bot] 8805104b8d chore: generate 2026-04-30 23:25:10 +00:00
Kit Langton fc155e9fc5 Build HttpApi UI route from services (#25177) 2026-04-30 19:24:10 -04:00
opencode-agent[bot] 3aaac0098e chore: generate 2026-04-30 23:06:57 +00:00
Sewer. a12333310f fix(provider): split providerOptions key on dot for openai-compatible, openai, and anthropic providers (#25145) 2026-04-30 18:05:56 -05:00
opencode-agent[bot] 247284b9af chore: generate 2026-04-30 22:51:09 +00:00
Kit Langton e0305e47f3 Protect HttpApi web UI fallback with auth (#25169) 2026-04-30 18:49:54 -04:00
Kit Langton 76a0f0f619 docs(httpapi): update migration spec to current state (#25173) 2026-04-30 18:41:27 -04:00
Aiden Cline 560baae15d fix: ensure user config takes precendence over plugin hooks for model resolution (#25167) 2026-04-30 17:15:56 -05:00
Kit Langton 5518ecaefe Fix HttpApi web UI fallback (#25163) 2026-04-30 17:43:18 -04:00
opencode-agent[bot] 924ba97055 chore: generate 2026-04-30 21:04:24 +00:00
Aiden Cline b80f52f8ad tweak: adjust codex plugin to use the models hook (#25157) 2026-04-30 16:03:07 -05:00
Kit Langton feb275d08b Remove covered workspace websocket todo (#25161) 2026-04-30 20:58:08 +00:00
Kit Langton fbcbd24063 Add SyncEvent service (#25158) 2026-04-30 16:45:26 -04:00
120 changed files with 1960 additions and 6817 deletions
+6
View File
@@ -36,3 +36,9 @@ jobs:
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} 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 }}
+7
View File
@@ -494,6 +494,13 @@ jobs:
working-directory: packages/desktop-electron working-directory: packages/desktop-electron
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} 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 - name: Package and publish
if: needs.version.outputs.release if: needs.version.outputs.release
+105 -12
View File
@@ -35,6 +35,7 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2", "@solid-primitives/audio": "1.4.2",
@@ -69,6 +70,7 @@
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "20.0.11", "@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:", "@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -230,6 +232,7 @@
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -250,6 +253,7 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
"@sentry/vite-plugin": "catalog:",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -275,6 +279,8 @@
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -456,7 +462,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
@@ -689,6 +694,8 @@
"@opentui/solid": "0.2.0", "@opentui/solid": "0.2.0",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
@@ -1069,8 +1076,6 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@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/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=="], "@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=="],
@@ -1959,6 +1964,44 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@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/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=="], "@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=="],
@@ -3243,7 +3286,7 @@
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
@@ -3737,7 +3780,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
@@ -4141,7 +4184,7 @@
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
@@ -4191,7 +4234,7 @@
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
@@ -4951,7 +4994,7 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"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=="], "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=="],
"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=="], "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=="],
@@ -5059,7 +5102,9 @@
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "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=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
@@ -5611,6 +5656,16 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@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-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=="], "@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=="],
@@ -5665,6 +5720,8 @@
"@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=="], "@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/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=="], "@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=="],
@@ -5849,8 +5906,6 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "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=="], "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=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -5961,7 +6016,7 @@
"ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
@@ -5973,6 +6028,8 @@
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "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=="], "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=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
@@ -6069,6 +6126,10 @@
"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=="], "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=="], "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=="], "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=="],
@@ -6569,6 +6630,16 @@
"@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=="], "@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/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=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -6591,6 +6662,8 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@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=="], "@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=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
@@ -6737,8 +6810,12 @@
"ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "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=="], "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=="], "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=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -6767,6 +6844,8 @@
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "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/@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=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@@ -6991,6 +7070,12 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@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=="], "@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=="], "@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=="],
@@ -7087,6 +7172,8 @@
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "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/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=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
@@ -7099,6 +7186,8 @@
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "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/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=="], "@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7153,6 +7242,8 @@
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@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": ["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=="], "@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=="],
@@ -7179,6 +7270,8 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "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=="], "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=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-cBfg4pJ4mjsfS4MFFASBaZZykArgIoeo/3woOcSGy1U=", "x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=",
"aarch64-linux": "sha256-Q6cqUwfqbscdrPW0uHcfshhQINjJi0HiyURMSdOOCf4=", "aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=",
"aarch64-darwin": "sha256-1AtfsD1D9YxWSEsecPJF9XsvsxsWTtVtkP5l6UW43og=", "aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=",
"x86_64-darwin": "sha256-YS5/8YTf9LymAUbjXVrGDfxtKVJrpZbPnnCtsGHSHoU=" "x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY="
} }
} }
+2
View File
@@ -77,6 +77,8 @@
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@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", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10" "@lydell/node-pty": "1.2.0-beta.10"
+2
View File
@@ -27,6 +27,7 @@
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "20.0.11", "@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:", "@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -40,6 +41,7 @@
}, },
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
+9 -1
View File
@@ -1,4 +1,5 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
@@ -148,12 +149,19 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
> >
<LanguageProvider locale={props.locale}> <LanguageProvider locale={props.locale}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary
fallback={(error) => {
Sentry.captureException(error)
return <ErrorPage error={error} />
}}
>
<QueryProvider>
<DialogProvider> <DialogProvider>
<MarkedProvider> <MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider> </MarkedProvider>
</DialogProvider> </DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -329,6 +329,7 @@ export const SettingsGeneral: Component = () => {
label={(o) => o.label} label={(o) => o.label}
onSelect={(option) => { onSelect={(option) => {
if (!option) return if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value }) globalSync.updateConfig({ shell: option.value })
}} }}
variant="secondary" variant="secondary"
+20
View File
@@ -1,5 +1,6 @@
// @refresh reload // @refresh reload
import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
@@ -125,6 +126,25 @@ const platform: Platform = {
setDefaultServer: writeDefaultServerUrl, 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) { if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render( render(
+4
View File
@@ -2,6 +2,10 @@ interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
readonly VITE_SENTRY_RELEASE?: string
} }
interface ImportMeta { interface ImportMeta {
+2
View File
@@ -402,6 +402,8 @@ export const dict = {
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.", "error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.details.label": "تفاصيل الخطأ", "error.page.details.label": "تفاصيل الخطأ",
"error.page.action.restart": "إعادة تشغيل", "error.page.action.restart": "إعادة تشغيل",
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.checking": "جارٍ التحقق...", "error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}", "error.page.action.updateTo": "تحديث إلى {{version}}",
+2
View File
@@ -403,6 +403,8 @@ export const dict = {
"error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.details.label": "Detalhes do Erro", "error.page.details.label": "Detalhes do Erro",
"error.page.action.restart": "Reiniciar", "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.checking": "Verificando...",
"error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.checkUpdates": "Verificar atualizações",
"error.page.action.updateTo": "Atualizar para {{version}}", "error.page.action.updateTo": "Atualizar para {{version}}",
+2
View File
@@ -449,6 +449,8 @@ export const dict = {
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
"error.page.details.label": "Detalji greške", "error.page.details.label": "Detalji greške",
"error.page.action.restart": "Restartuj", "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.checking": "Provjera...",
"error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.checkUpdates": "Provjeri ažuriranja",
"error.page.action.updateTo": "Ažuriraj na {{version}}", "error.page.action.updateTo": "Ažuriraj na {{version}}",
+2
View File
@@ -446,6 +446,8 @@ export const dict = {
"error.page.description": "Der opstod en fejl under indlæsning af applikationen.", "error.page.description": "Der opstod en fejl under indlæsning af applikationen.",
"error.page.details.label": "Fejldetaljer", "error.page.details.label": "Fejldetaljer",
"error.page.action.restart": "Genstart", "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.checking": "Tjekker...",
"error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.checkUpdates": "Tjek for opdateringer",
"error.page.action.updateTo": "Opdater til {{version}}", "error.page.action.updateTo": "Opdater til {{version}}",
+2
View File
@@ -410,6 +410,8 @@ export const dict = {
"error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.", "error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.",
"error.page.details.label": "Fehlerdetails", "error.page.details.label": "Fehlerdetails",
"error.page.action.restart": "Neustart", "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.checking": "Prüfen...",
"error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.checkUpdates": "Nach Updates suchen",
"error.page.action.updateTo": "Auf {{version}} aktualisieren", "error.page.action.updateTo": "Auf {{version}} aktualisieren",
+2
View File
@@ -465,6 +465,8 @@ export const dict = {
"error.page.description": "An error occurred while loading the application.", "error.page.description": "An error occurred while loading the application.",
"error.page.details.label": "Error Details", "error.page.details.label": "Error Details",
"error.page.action.restart": "Restart", "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.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates", "error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}", "error.page.action.updateTo": "Update to {{version}}",
+2
View File
@@ -449,6 +449,8 @@ export const dict = {
"error.page.description": "Ocurrió un error al cargar la aplicación.", "error.page.description": "Ocurrió un error al cargar la aplicación.",
"error.page.details.label": "Detalles del error", "error.page.details.label": "Detalles del error",
"error.page.action.restart": "Reiniciar", "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.checking": "Comprobando...",
"error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.checkUpdates": "Buscar actualizaciones",
"error.page.action.updateTo": "Actualizar a {{version}}", "error.page.action.updateTo": "Actualizar a {{version}}",
+2
View File
@@ -406,6 +406,8 @@ export const dict = {
"error.page.description": "Une erreur s'est produite lors du chargement de l'application.", "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.details.label": "Détails de l'erreur",
"error.page.action.restart": "Redémarrer", "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.checking": "Vérification...",
"error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.checkUpdates": "Vérifier les mises à jour",
"error.page.action.updateTo": "Mettre à jour vers {{version}}", "error.page.action.updateTo": "Mettre à jour vers {{version}}",
+2
View File
@@ -402,6 +402,8 @@ export const dict = {
"error.page.description": "アプリケーションの読み込み中にエラーが発生しました。", "error.page.description": "アプリケーションの読み込み中にエラーが発生しました。",
"error.page.details.label": "エラー詳細", "error.page.details.label": "エラー詳細",
"error.page.action.restart": "再起動", "error.page.action.restart": "再起動",
"error.page.action.report": "エラーを報告",
"error.page.action.reported": "エラーを報告しました",
"error.page.action.checking": "確認中...", "error.page.action.checking": "確認中...",
"error.page.action.checkUpdates": "アップデートを確認", "error.page.action.checkUpdates": "アップデートを確認",
"error.page.action.updateTo": "{{version}}にアップデート", "error.page.action.updateTo": "{{version}}にアップデート",
+2
View File
@@ -401,6 +401,8 @@ export const dict = {
"error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.", "error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.",
"error.page.details.label": "오류 세부 정보", "error.page.details.label": "오류 세부 정보",
"error.page.action.restart": "다시 시작", "error.page.action.restart": "다시 시작",
"error.page.action.report": "오류 신고",
"error.page.action.reported": "오류가 신고됨",
"error.page.action.checking": "확인 중...", "error.page.action.checking": "확인 중...",
"error.page.action.checkUpdates": "업데이트 확인", "error.page.action.checkUpdates": "업데이트 확인",
"error.page.action.updateTo": "{{version}} 버전으로 업데이트", "error.page.action.updateTo": "{{version}} 버전으로 업데이트",
+2
View File
@@ -450,6 +450,8 @@ export const dict = {
"error.page.description": "Det oppstod en feil under lasting av applikasjonen.", "error.page.description": "Det oppstod en feil under lasting av applikasjonen.",
"error.page.details.label": "Feildetaljer", "error.page.details.label": "Feildetaljer",
"error.page.action.restart": "Start på nytt", "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.checking": "Sjekker...",
"error.page.action.checkUpdates": "Se etter oppdateringer", "error.page.action.checkUpdates": "Se etter oppdateringer",
"error.page.action.updateTo": "Oppdater til {{version}}", "error.page.action.updateTo": "Oppdater til {{version}}",
+2
View File
@@ -403,6 +403,8 @@ export const dict = {
"error.page.description": "Wystąpił błąd podczas ładowania aplikacji.", "error.page.description": "Wystąpił błąd podczas ładowania aplikacji.",
"error.page.details.label": "Szczegóły błędu", "error.page.details.label": "Szczegóły błędu",
"error.page.action.restart": "Restartuj", "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.checking": "Sprawdzanie...",
"error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.checkUpdates": "Sprawdź aktualizacje",
"error.page.action.updateTo": "Zaktualizuj do {{version}}", "error.page.action.updateTo": "Zaktualizuj do {{version}}",
+2
View File
@@ -448,6 +448,8 @@ export const dict = {
"error.page.description": "Произошла ошибка при загрузке приложения.", "error.page.description": "Произошла ошибка при загрузке приложения.",
"error.page.details.label": "Детали ошибки", "error.page.details.label": "Детали ошибки",
"error.page.action.restart": "Перезапустить", "error.page.action.restart": "Перезапустить",
"error.page.action.report": "Сообщить об ошибке",
"error.page.action.reported": "Об ошибке сообщено",
"error.page.action.checking": "Проверка...", "error.page.action.checking": "Проверка...",
"error.page.action.checkUpdates": "Проверить обновления", "error.page.action.checkUpdates": "Проверить обновления",
"error.page.action.updateTo": "Обновить до {{version}}", "error.page.action.updateTo": "Обновить до {{version}}",
+2
View File
@@ -447,6 +447,8 @@ export const dict = {
"error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน", "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน",
"error.page.details.label": "รายละเอียดข้อผิดพลาด", "error.page.details.label": "รายละเอียดข้อผิดพลาด",
"error.page.action.restart": "รีสตาร์ท", "error.page.action.restart": "รีสตาร์ท",
"error.page.action.report": "รายงานข้อผิดพลาด",
"error.page.action.reported": "รายงานข้อผิดพลาดแล้ว",
"error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checking": "กำลังตรวจสอบ...",
"error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต",
"error.page.action.updateTo": "อัปเดตเป็น {{version}}", "error.page.action.updateTo": "อัปเดตเป็น {{version}}",
+2
View File
@@ -452,6 +452,8 @@ export const dict = {
"error.page.description": "Uygulama yüklenirken bir hata oluştu.", "error.page.description": "Uygulama yüklenirken bir hata oluştu.",
"error.page.details.label": "Hata Detayları", "error.page.details.label": "Hata Detayları",
"error.page.action.restart": "Yeniden Başlat", "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.checking": "Kontrol ediliyor...",
"error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.checkUpdates": "Güncellemeleri kontrol et",
"error.page.action.updateTo": "{{version}} sürümüne güncelle", "error.page.action.updateTo": "{{version}} sürümüne güncelle",
+2
View File
@@ -452,6 +452,8 @@ export const dict = {
"error.page.description": "加载应用程序时发生错误。", "error.page.description": "加载应用程序时发生错误。",
"error.page.details.label": "错误详情", "error.page.details.label": "错误详情",
"error.page.action.restart": "重启", "error.page.action.restart": "重启",
"error.page.action.report": "上报错误",
"error.page.action.reported": "错误已上报",
"error.page.action.checking": "检查中...", "error.page.action.checking": "检查中...",
"error.page.action.checkUpdates": "检查更新", "error.page.action.checkUpdates": "检查更新",
"error.page.action.updateTo": "更新到 {{version}}", "error.page.action.updateTo": "更新到 {{version}}",
+2
View File
@@ -445,6 +445,8 @@ export const dict = {
"error.page.description": "載入應用程式時發生錯誤。", "error.page.description": "載入應用程式時發生錯誤。",
"error.page.details.label": "錯誤詳情", "error.page.details.label": "錯誤詳情",
"error.page.action.restart": "重新啟動", "error.page.action.restart": "重新啟動",
"error.page.action.report": "回報錯誤",
"error.page.action.reported": "已回報錯誤",
"error.page.action.checking": "檢查中...", "error.page.action.checking": "檢查中...",
"error.page.action.checkUpdates": "檢查更新", "error.page.action.checkUpdates": "檢查更新",
"error.page.action.updateTo": "更新到 {{version}}", "error.page.action.updateTo": "更新到 {{version}}",
+20 -2
View File
@@ -1,7 +1,8 @@
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import * as Sentry from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js" import { Component, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -270,10 +271,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
label={language.t("error.page.details.label")} label={language.t("error.page.details.label")}
hideLabel hideLabel
/> />
<div class="flex items-center gap-3"> <div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64">
<Button size="large" onClick={platform.restart}> <Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")} {language.t("error.page.action.restart")}
</Button> </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={platform.checkUpdate}>
<Show <Show
when={store.version} when={store.version}
+20 -2
View File
@@ -1,8 +1,26 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "vite" import { defineConfig } from "vite"
import desktopPlugin 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({ export default defineConfig({
plugins: [desktopPlugin] as any, plugins: [desktopPlugin, sentry] as any,
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: true, allowedHosts: true,
@@ -10,6 +28,6 @@ export default defineConfig({
}, },
build: { build: {
target: "esnext", target: "esnext",
// sourcemap: true, sourcemap: true,
}, },
}) })
+5
View File
@@ -11,6 +11,7 @@ const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app) const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app) const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app) const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)
const paths = { const paths = {
get home() { get home() {
@@ -22,6 +23,7 @@ const paths = {
cache, cache,
config, config,
state, state,
tmp,
} }
export const Path = paths export const Path = paths
@@ -32,6 +34,7 @@ await Promise.all([
fs.mkdir(Path.data, { recursive: true }), fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }), fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }), fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }),
]) ])
@@ -44,6 +47,7 @@ export interface Interface {
readonly cache: string readonly cache: string
readonly config: string readonly config: string
readonly state: string readonly state: string
readonly tmp: string
readonly bin: string readonly bin: string
readonly log: string readonly log: string
} }
@@ -55,6 +59,7 @@ export function make(input: Partial<Interface> = {}): Interface {
cache: Path.cache, cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config, config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
state: Path.state, state: Path.state,
tmp: Path.tmp,
bin: Path.bin, bin: Path.bin,
log: Path.log, log: Path.log,
...input, ...input,
+1 -8
View File
@@ -2,13 +2,6 @@
"$schema": "https://json.schemastore.org/tsconfig", "$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json", "extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noUncheckedIndexedAccess": false, "noUncheckedIndexedAccess": false
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
} }
} }
@@ -1,3 +1,4 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "electron-vite" import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite" import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
@@ -12,6 +13,23 @@ const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` 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({ export default defineConfig({
main: { main: {
define: { define: {
@@ -61,13 +79,14 @@ export default defineConfig({
}, },
}, },
renderer: { renderer: {
plugins: [appPlugin], plugins: [appPlugin, sentry],
publicDir: "../../../app/public", publicDir: "../../../app/public",
root: "src/renderer", root: "src/renderer",
define: { define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
}, },
build: { build: {
sourcemap: true,
rollupOptions: { rollupOptions: {
input: { input: {
main: "src/renderer/index.html", main: "src/renderer/index.html",
+2
View File
@@ -38,6 +38,8 @@
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -14,6 +14,7 @@ import {
ServerConnection, ServerConnection,
useCommand, useCommand,
} from "@opencode-ai/app" } from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage" import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router" import { MemoryRouter } from "@solidjs/router"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
@@ -29,6 +30,25 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(t("error.dev.rootNotFound")) 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() void initI18n()
const deepLinkEvent = "opencode:deep-link" const deepLinkEvent = "opencode:deep-link"
+2
View File
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
@@ -35,6 +36,7 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
"@sentry/vite-plugin": "catalog:",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
+9
View File
@@ -0,0 +1,9 @@
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,6 +14,7 @@ import {
ServerConnection, ServerConnection,
useCommand, useCommand,
} from "@opencode-ai/app" } from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage" import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window" import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { readImage } from "@tauri-apps/plugin-clipboard-manager"
+3 -3
View File
@@ -15,9 +15,9 @@ export default defineConfig({
// Improves production stack traces // Improves production stack traces
keepNames: true, keepNames: true,
}, },
// build: { build: {
// sourcemap: true, sourcemap: true,
// }, },
// 2. tauri expects a fixed port, fail if that port is not available // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
-2
View File
@@ -6,7 +6,6 @@
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
@@ -42,7 +41,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
+28 -16
View File
@@ -12,14 +12,16 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi
## Current State ## Current State
- `OPENCODE_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono. - `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes. - `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 for default behavior and for `hono-openapi` SDK generation. - Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
- `HttpApi` auth is independent of Hono auth. - 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.
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`. - `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`. - 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`. - 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`. - `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 ## Migration Rules
@@ -122,10 +124,19 @@ Keep large or stateful groups for later:
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation. Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
Required before route deletion: Required before route deletion:
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes. - Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional. - 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. - 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. - Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
@@ -365,25 +376,26 @@ 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. 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. 9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes. 10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP. 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. 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. 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. 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 ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files. 15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
## Checklist ## Checklist
- [x] Add first `HttpApi` JSON route slices. - [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes into Hono behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. - [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
- [x] Reuse existing Effect services in handlers. - [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer. - [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Attach auth middleware in route modules. - [x] Centralize auth via Effect `Config` for the Effect backend.
- [x] Support `auth_token` as a query security scheme. - [x] Support `auth_token` as a query security scheme.
- [x] Add bridge-level auth and instance tests. - [x] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory. - [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups. - [x] Resolve implemented-but-unmounted route groups.
- [x] Port remaining top-level JSON reads. - [x] Port remaining top-level JSON reads.
- [ ] Generate SDK/OpenAPI from Effect routes. - [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`.
- [ ] Flip ported JSON routes to default-on with fallback. - [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
- [ ] Delete replaced Hono route implementations. - [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations. - [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
+5 -1
View File
@@ -81,7 +81,11 @@ export const layer = Layer.effect(
Effect.fn("Agent.state")(function* (ctx) { Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const skillDirs = yield* skill.dirs() const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
]
const defaults = Permission.fromConfig({ const defaults = Permission.fromConfig({
"*": "allow", "*": "allow",
+1 -4
View File
@@ -245,10 +245,7 @@ export const ExportCommand = cmd({
output: process.stderr, output: process.stderr,
}) })
const sessions = [] const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
for await (const session of Session.list()) {
sessions.push(session)
}
if (sessions.length === 0) { if (sessions.length === 0) {
prompts.log.error("No sessions found", { prompts.log.error("No sessions found", {
+3 -1
View File
@@ -91,7 +91,9 @@ export const SessionListCommand = cmd({
}, },
handler: async (args) => { handler: async (args) => {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const sessions = [...Session.list({ roots: true, limit: args.maxCount })] const sessions = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
)
if (sessions.length === 0) { if (sessions.length === 0) {
return return
@@ -51,7 +51,7 @@ export function createDialogProviderOptions() {
}[provider.id], }[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", 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() { async onSelect() {
if (consoleManaged) return if (consoleManaged) return
@@ -168,7 +168,7 @@ export function DialogSessionList() {
value: x.id, value: x.id,
category, category,
footer, footer,
gutter: isWorking ? <Spinner /> : undefined, gutter: isWorking ? () => <Spinner /> : undefined,
} }
}) })
}) })
@@ -189,13 +189,20 @@ export function resolveZedDbPath() {
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"), path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item)) ].filter((item): item is string => Boolean(item))
return candidates.find((item) => Filesystem.stat(item)?.isFile()) return candidates.find((item) => isFile(item))
}
function isFile(item: string) {
try {
return Filesystem.stat(item)?.isFile() === true
} catch {
return false
}
} }
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) { function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
return zedWorkspacePaths(workspacePaths).reduce((score, item) => { return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
if (pathContains(item, cwd)) return Math.max(score, 2) if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
if (pathContains(cwd, item)) return Math.max(score, 1)
return score return score
}, 0) }, 0)
} }
@@ -42,7 +42,7 @@ export interface DialogSelectOption<T = any> {
categoryView?: JSX.Element categoryView?: JSX.Element
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
gutter?: JSX.Element gutter?: () => JSX.Element
margin?: JSX.Element margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext) => void
} }
@@ -407,7 +407,7 @@ function Option(props: {
active?: boolean active?: boolean
current?: boolean current?: boolean
footer?: JSX.Element | string footer?: JSX.Element | string
gutter?: JSX.Element gutter?: () => JSX.Element
onMouseOver?: () => void onMouseOver?: () => void
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
@@ -422,7 +422,7 @@ function Option(props: {
</Show> </Show>
<Show when={!props.current && props.gutter}> <Show when={!props.current && props.gutter}>
<box flexShrink={0} marginRight={0}> <box flexShrink={0} marginRight={0}>
{props.gutter} {props.gutter?.()}
</box> </box>
</Show> </Show>
<text <text
+20 -12
View File
@@ -3,7 +3,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
import { mergeDeep, pipe } from "remeda" import { mergeDeep } from "remeda"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises" import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
@@ -47,8 +47,13 @@ import { Npm } from "@opencode-ai/core/npm"
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
// Custom merge function that concatenates array fields instead of replacing them // 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 { function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source) const merged = mergeConfig(target, source)
if (target.instructions && source.instructions) { if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
} }
@@ -387,12 +392,10 @@ export const layer = Layer.effect(
}) })
const loadGlobal = Effect.fnUntraced(function* () { const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = pipe( let result: Info = {}
{}, result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config") const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) { if (existsSync(legacy)) {
@@ -402,7 +405,7 @@ export const layer = Layer.effect(
const { provider, model, ...rest } = mod.default const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}` if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json" result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest) result = mergeConfig(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy) await fsNode.unlink(legacy)
}) })
@@ -759,18 +762,23 @@ export const layer = Layer.effect(
const patch = writableGlobal(config) const patch = writableGlobal(config)
let next: Info let next: Info
let changed: boolean
if (!file.endsWith(".jsonc")) { if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), patch) const merged = mergeDeep(writable(existing), patch)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) const serialized = JSON.stringify(merged, null, 2)
changed = serialized !== before
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
next = merged next = merged
} else { } else {
const updated = patchJsonc(before, patch) const updated = patchJsonc(before, patch)
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file) next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie) changed = updated !== before
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
} }
yield* invalidate() // Only tear down running instances if the config actually changed.
if (changed) yield* invalidate()
return next return next
}) })
@@ -169,6 +169,7 @@ export const layer = Layer.effect(
const auth = yield* Auth.Service const auth = yield* Auth.Service
const session = yield* Session.Service const session = yield* Session.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const connections = new Map<WorkspaceID, ConnectionStatus>() const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>() const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -307,12 +308,15 @@ export const layer = Layer.effect(
events: events.length, events: events.length,
}) })
yield* Effect.sync(() => yield* Effect.promise(async () => {
WorkspaceContext.provide({ await WorkspaceContext.provide({
workspaceID: space.id, workspaceID: space.id,
fn: () => { async fn() {
for (const event of events) { await Effect.runPromise(
SyncEvent.replay( Effect.forEach(
events,
(event) =>
sync.replay(
{ {
id: event.id, id: event.id,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
@@ -321,11 +325,13 @@ export const layer = Layer.effect(
data: event.data, data: event.data,
}, },
{ publish: true }, { publish: true },
),
{ discard: true },
),
) )
}
}, },
}), })
) })
}) })
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
@@ -361,16 +367,28 @@ export const layer = Layer.effect(
setStatus(space.id, "connected") setStatus(space.id, "connected")
yield* parseSSE(stream, (evt) => yield* parseSSE(stream, (evt) =>
Effect.sync(() => { Effect.gen(function* () {
try {
if (!evt || typeof evt !== "object" || !("payload" in evt)) return if (!evt || typeof evt !== "object" || !("payload" in evt)) return
const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent } const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent }
if (payload.type === "server.heartbeat") return if (payload.type === "server.heartbeat") return
if (payload.type === "sync" && payload.syncEvent) { if (payload.type === "sync" && payload.syncEvent) {
SyncEvent.replay(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 } const event = evt as { directory?: string; project?: string; payload: unknown }
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: event.directory, directory: event.directory,
@@ -378,10 +396,10 @@ export const layer = Layer.effect(
workspace: space.id, workspace: space.id,
payload: event.payload, payload: event.payload,
}) })
} catch (err) { } catch (error) {
log.info("failed to replay global event", { log.info("failed to replay global event", {
workspaceID: space.id, workspaceID: space.id,
error: err, error,
}) })
} }
}), }),
@@ -516,14 +534,12 @@ export const layer = Layer.effect(
const adaptor = getAdaptor(space.projectID, space.type) const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
yield* Effect.sync(() => yield* sync.run(Session.Event.Updated, {
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID, sessionID: input.sessionID,
info: { info: {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
}, },
}), })
)
const rows = yield* db((db) => const rows = yield* db((db) =>
db db
@@ -593,7 +609,7 @@ export const layer = Layer.effect(
}) })
if (target.type === "local") { if (target.type === "local") {
SyncEvent.replayAll(events) yield* sync.replayAll(events)
log.info("session restore batch replayed locally", { log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -812,6 +828,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
) )
@@ -47,6 +47,7 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map" import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -97,6 +98,7 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer, Installation.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
SessionShare.defaultLayer, SessionShare.defaultLayer,
SyncEvent.defaultLayer,
).pipe(Layer.provideMerge(Observability.layer)) ).pipe(Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap }) const rt = ManagedRuntime.make(AppLayer, { memoMap })
+10 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Fiber } from "effect" import { Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance" import { Instance, type InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema" import type { WorkspaceID } from "@/control-plane/schema"
@@ -9,6 +9,7 @@ import { attachWith } from "./run-service"
export interface Shape { export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A> 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 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 { function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
@@ -43,6 +44,14 @@ export function make(): Effect.Effect<Shape> {
restore(instance, workspace, () => Effect.runPromise(wrap(effect))), restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))), 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 } satisfies Shape
}) })
} }
+10 -14
View File
@@ -10,7 +10,6 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Git } from "@/git" import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { FileIgnore } from "./ignore" import { FileIgnore } from "./ignore"
@@ -76,25 +75,27 @@ export const layer = Layer.effect(
function* () { function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory }) const ctx = yield* InstanceState.context
log.info("init", { directory: ctx.directory })
const backend = getBackend() const backend = getBackend()
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
return return
} }
const w = watcher() const w = watcher()
if (!w) return if (!w) return
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = [] const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
) )
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
if (err) return if (err) return
for (const evt of evts) { for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
@@ -122,19 +123,14 @@ export const layer = Layer.effect(
const cfgIgnores = cfg.watcher?.ignore ?? [] const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [ yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
} }
if (Instance.project.vcs === "git") { if (ctx.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], { const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree, cwd: ctx.worktree,
}) })
const vcsDir = const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD", (entry) => entry !== "HEAD",
+30 -34
View File
@@ -14,6 +14,7 @@ const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455 const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 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 { interface PkceCodes {
verifier: string verifier: string
@@ -358,49 +359,44 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> { export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return { return {
auth: { provider: {
provider: "openai", id: "openai",
async loader(getAuth, provider) { async models(provider, ctx) {
const auth = await getAuth() if (ctx.auth?.type !== "oauth") return provider.models
if (auth.type !== "oauth") return {}
// Filter models to only allowed Codex models for OAuth return Object.fromEntries(
const allowedModels = new Set([ Object.entries(provider.models)
"gpt-5.1-codex", .filter(([, model]) => {
"gpt-5.1-codex-max", if (ALLOWED_MODELS.has(model.api.id)) return true
"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+)/) const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
if (match && parseFloat(match[1]) > 5.4) continue return match ? parseFloat(match[1]) > 5.4 : false
delete provider.models[modelId] })
} .map(([modelID, model]) => [
modelID,
// Zero out costs for Codex (included with ChatGPT subscription) {
for (const model of Object.values(provider.models)) { ...model,
model.cost = { cost: {
input: 0, input: 0,
output: 0, output: 0,
cache: { read: 0, write: 0 }, cache: { read: 0, write: 0 },
} },
limit: model.id.includes("gpt-5.5")
// 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, context: 400_000,
//@ts-expect-error incorrect type for v1 sdk but works
input: 272_000, input: 272_000,
output: 128_000, output: 128_000,
} }
} : model.limit,
} },
]),
)
},
},
auth: {
provider: "openai",
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
return { return {
apiKey: OAUTH_DUMMY_KEY, apiKey: OAUTH_DUMMY_KEY,
+5 -3
View File
@@ -7,7 +7,7 @@ import * as Project from "./project"
import * as Vcs from "./vcs" import * as Vcs from "./vcs"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { InstanceState } from "@/effect/instance-state"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
@@ -15,7 +15,8 @@ import * as Effect from "effect/Effect"
import { Config } from "@/config/config" import { Config } from "@/config/config"
export const InstanceBootstrap = Effect.gen(function* () { export const InstanceBootstrap = Effect.gen(function* () {
Log.Default.info("bootstrapping", { directory: Instance.directory }) const ctx = yield* InstanceState.context
Log.Default.info("bootstrapping", { directory: ctx.directory })
// everything depends on config so eager load it for nice traces // everything depends on config so eager load it for nice traces
yield* Config.Service.use((svc) => svc.get()) yield* Config.Service.use((svc) => svc.get())
// Plugin can mutate config so it has to be initialized before anything else. // Plugin can mutate config so it has to be initialized before anything else.
@@ -32,10 +33,11 @@ export const InstanceBootstrap = Effect.gen(function* () {
].map((s) => Effect.forkDetach(s.use((i) => i.init()))), ].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
).pipe(Effect.withSpan("InstanceBootstrap.init")) ).pipe(Effect.withSpan("InstanceBootstrap.init"))
const projectID = ctx.project.id
yield* Bus.Service.use((svc) => yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => { svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id) Project.setInitialized(projectID)
} }
}), }),
) )
+10 -10
View File
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema" import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@/effect/service-use"
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
@@ -24,13 +24,13 @@ const log = Log.create({ service: "project" })
const ProjectVcs = Schema.Literal("git") const ProjectVcs = Schema.Literal("git")
const ProjectIcon = Schema.Struct({ const ProjectIcon = Schema.Struct({
url: Schema.optional(Schema.String), url: optionalOmitUndefined(Schema.String),
override: Schema.optional(Schema.String), override: optionalOmitUndefined(Schema.String),
color: Schema.optional(Schema.String), color: optionalOmitUndefined(Schema.String),
}) })
const ProjectCommands = Schema.Struct({ const ProjectCommands = Schema.Struct({
start: Schema.optional( start: optionalOmitUndefined(
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), 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({ const ProjectTime = Schema.Struct({
created: NonNegativeInt, created: NonNegativeInt,
updated: NonNegativeInt, updated: NonNegativeInt,
initialized: Schema.optional(NonNegativeInt), initialized: optionalOmitUndefined(NonNegativeInt),
}) })
export const Info = Schema.Struct({ export const Info = Schema.Struct({
id: ProjectID, id: ProjectID,
worktree: Schema.String, worktree: Schema.String,
vcs: Schema.optional(ProjectVcs), vcs: optionalOmitUndefined(ProjectVcs),
name: Schema.optional(Schema.String), name: optionalOmitUndefined(Schema.String),
icon: Schema.optional(ProjectIcon), icon: optionalOmitUndefined(ProjectIcon),
commands: Schema.optional(ProjectCommands), commands: optionalOmitUndefined(ProjectCommands),
time: ProjectTime, time: ProjectTime,
sandboxes: Schema.Array(Schema.String), sandboxes: Schema.Array(Schema.String),
}) })
+12 -10
View File
@@ -3,7 +3,7 @@ import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error" import { namedSchemaError } from "@/util/named-schema-error"
import { withStatics } from "@/util/schema" import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
@@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
placeholder: Schema.optional(Schema.String), placeholder: optionalOmitUndefined(Schema.String),
when: Schema.optional(When), when: optionalOmitUndefined(When),
}) })
const SelectOption = Schema.Struct({ const SelectOption = Schema.Struct({
label: Schema.String, label: Schema.String,
value: Schema.String, value: Schema.String,
hint: Schema.optional(Schema.String), hint: optionalOmitUndefined(Schema.String),
}) })
const SelectPrompt = Schema.Struct({ const SelectPrompt = Schema.Struct({
@@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
options: Schema.Array(SelectOption), options: Schema.Array(SelectOption),
when: Schema.optional(When), when: optionalOmitUndefined(When),
}) })
const Prompt = Schema.Union([TextPrompt, SelectPrompt]) const Prompt = Schema.Union([TextPrompt, SelectPrompt])
@@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt])
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({ export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
type: Schema.Literals(["oauth", "api"]), type: Schema.Literals(["oauth", "api"]),
label: Schema.String, label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)), prompts: optionalOmitUndefined(Schema.Array(Prompt)),
}) { }) {
static readonly zod = zod(this) static readonly zod = zod(this)
} }
@@ -135,24 +135,26 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
item.methods.map((method) => ({ item.methods.map((method) => ({
type: method.type, type: method.type,
label: method.label, label: method.label,
prompts: method.prompts?.map((prompt) => { ...(method.prompts && {
prompts: method.prompts.map((prompt) => {
if (prompt.type === "select") { if (prompt.type === "select") {
return { return {
type: "select" as const, type: "select" as const,
key: prompt.key, key: prompt.key,
message: prompt.message, message: prompt.message,
options: prompt.options, options: prompt.options,
when: prompt.when, ...(prompt.when && { when: prompt.when }),
} }
} }
return { return {
type: "text" as const, type: "text" as const,
key: prompt.key, key: prompt.key,
message: prompt.message, message: prompt.message,
placeholder: prompt.placeholder, ...(prompt.placeholder && { placeholder: prompt.placeholder }),
when: prompt.when, ...(prompt.when && { when: prompt.when }),
} }
}), }),
}),
})), })),
), ),
) )
+33 -33
View File
@@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { withStatics } from "@/util/schema" import { optionalOmitUndefined, withStatics } from "@/util/schema"
import * as ProviderTransform from "./transform" import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema" import { ModelID, ProviderID } from "./schema"
@@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({
input: Schema.Finite, input: Schema.Finite,
output: Schema.Finite, output: Schema.Finite,
cache: ProviderCacheCost, cache: ProviderCacheCost,
experimentalOver200K: Schema.optional( experimentalOver200K: optionalOmitUndefined(
Schema.Struct({ Schema.Struct({
input: Schema.Finite, input: Schema.Finite,
output: Schema.Finite, output: Schema.Finite,
@@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({
const ProviderLimit = Schema.Struct({ const ProviderLimit = Schema.Struct({
context: Schema.Finite, context: Schema.Finite,
input: Schema.optional(Schema.Finite), input: optionalOmitUndefined(Schema.Finite),
output: Schema.Finite, output: Schema.Finite,
}) })
@@ -895,7 +895,7 @@ export const Model = Schema.Struct({
providerID: ProviderID, providerID: ProviderID,
api: ProviderApiInfo, api: ProviderApiInfo,
name: Schema.String, name: Schema.String,
family: Schema.optional(Schema.String), family: optionalOmitUndefined(Schema.String),
capabilities: ProviderCapabilities, capabilities: ProviderCapabilities,
cost: ProviderCost, cost: ProviderCost,
limit: ProviderLimit, limit: ProviderLimit,
@@ -903,7 +903,7 @@ export const Model = Schema.Struct({
options: Schema.Record(Schema.String, Schema.Any), options: Schema.Record(Schema.String, Schema.Any),
headers: Schema.Record(Schema.String, Schema.String), headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String, release_date: Schema.String,
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
}) })
.annotate({ identifier: "Model" }) .annotate({ identifier: "Model" })
.pipe(withStatics((s) => ({ zod: zod(s) }))) .pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -914,7 +914,7 @@ export const Info = Schema.Struct({
name: Schema.String, name: Schema.String,
source: Schema.Literals(["env", "config", "custom", "api"]), source: Schema.Literals(["env", "config", "custom", "api"]),
env: Schema.Array(Schema.String), env: Schema.Array(Schema.String),
key: Schema.optional(Schema.String), key: optionalOmitUndefined(Schema.String),
options: Schema.Record(Schema.String, Schema.Any), options: Schema.Record(Schema.String, Schema.Any),
models: Schema.Record(Schema.String, Model), models: Schema.Record(Schema.String, Model),
}) })
@@ -1140,6 +1140,33 @@ const layer: Layer.Layer<
return true 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 // extend database from config
for (const [providerID, provider] of configProviders) { for (const [providerID, provider] of configProviders) {
const existing = database[providerID] const existing = database[providerID]
@@ -1326,33 +1353,6 @@ 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)) { for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id) const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) { if (!isProviderAllowed(providerID)) {
+10 -1
View File
@@ -1058,7 +1058,16 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
return result return result
} }
const key = sdkKey(model.api.npm) ?? model.providerID // 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)
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks // providerOptions["openai"], but OpenAIResponsesLanguageModel checks
// "azure" first. Pass both so model options work on either code path. // "azure" first. Pass both so model options work on either code path.
+3 -1
View File
@@ -1,6 +1,8 @@
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) { export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
if (!input) return true if (!input) return true
if (input.startsWith("http://localhost:")) return true if (input.startsWith("http://localhost:")) return true
if (input.startsWith("http://127.0.0.1:")) return true if (input.startsWith("http://127.0.0.1:")) return true
+2 -2
View File
@@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors" import { cors } from "hono/cors"
import { compress } from "hono/compress" import { compress } from "hono/compress"
import * as ServerBackend from "./backend" import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin } from "./cors" import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -67,7 +67,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
} }
} }
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
return cors({ return cors({
maxAge: 86_400, maxAge: 86_400,
origin(input) { origin(input) {
@@ -0,0 +1,35 @@
# 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 * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -16,7 +16,7 @@ export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event") HttpApiGroup.make("event")
.add( .add(
HttpApiEndpoint.get("subscribe", EventPaths.event, { HttpApiEndpoint.get("subscribe", EventPaths.event, {
success: Schema.Unknown, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "event.subscribe", identifier: "event.subscribe",
@@ -37,11 +37,7 @@ function eventData(data: unknown): Sse.Event {
} }
} }
export const eventRoute = HttpRouter.add( function eventResponse(bus: Bus.Interface) {
"GET",
EventPaths.event,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe( const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1), Stream.drop(1),
@@ -66,5 +62,16 @@ export const eventRoute = HttpRouter.add(
}, },
}, },
) )
}).pipe(Effect.provide(Bus.layer)), }
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return eventResponse(bus)
}),
)
}),
) )
@@ -1,11 +1,10 @@
import * as InstanceState from "@/effect/instance-state" import * as InstanceState from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Command } from "@/command" import { Command } from "@/command"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session/compaction"
@@ -18,9 +17,8 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema" import { MessageID, PartID, SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage" import { NotFoundError } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Schema } from "effect" import { Cause, Effect, Schema, Scope } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
@@ -40,8 +38,6 @@ import {
UpdatePayload, UpdatePayload,
} from "../groups/session" } from "../groups/session"
const log = Log.create({ service: "server" })
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) => const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe( self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
@@ -63,12 +59,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const statusSvc = yield* SessionStatus.Service const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.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 }) { const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
const instance = yield* InstanceState.context return yield* session.list({
return Instance.restore(instance, () =>
Array.from(
Session.list({
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory, directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope, scope: ctx.query.scope,
path: ctx.query.path, path: ctx.query.path,
@@ -76,9 +71,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
start: ctx.query.start, start: ctx.query.start,
search: ctx.query.search, search: ctx.query.search,
limit: ctx.query.limit, limit: ctx.query.limit,
}), })
),
)
}) })
const status = Effect.fn("SessionHttpApi.status")(function* () { const status = Effect.fn("SessionHttpApi.status")(function* () {
@@ -261,17 +254,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof PromptPayload.Type payload: typeof PromptPayload.Type
}) { }) {
const bridge = yield* EffectBridge.make() const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
return HttpServerResponse.stream( return HttpServerResponse.stream(
Stream.fromEffect( Stream.fromEffect(
Effect.promise(() => promptSvc
bridge.promise( .prompt({
promptSvc.prompt({
...ctx.payload, ...ctx.payload,
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput), })
), .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
),
).pipe( ).pipe(
Stream.map((message) => JSON.stringify(message)), Stream.map((message) => JSON.stringify(message)),
Stream.encodeText, Stream.encodeText,
@@ -284,24 +276,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof PromptPayload.Type payload: typeof PromptPayload.Type
}) { }) {
const bridge = yield* EffectBridge.make() yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
yield* Effect.sync(() => { Effect.catchCause((cause) =>
bridge.fork( Effect.gen(function* () {
promptSvc yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) yield* bus.publish(Session.Event.Error, {
.pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: String(error) }).toObject(), error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
}) })
}), }),
), ),
), Effect.forkIn(scope, { startImmediately: true }),
) )
})
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}) })
@@ -309,14 +295,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof CommandPayload.Type payload: typeof CommandPayload.Type
}) { }) {
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput) return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
}) })
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID } params: { sessionID: SessionID }
payload: typeof ShellPayload.Type payload: typeof ShellPayload.Type
}) { }) {
return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput) return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID })
}) })
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
@@ -21,6 +21,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
Effect.gen(function* () { Effect.gen(function* () {
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const start = Effect.fn("SyncHttpApi.start")(function* () { const start = Effect.fn("SyncHttpApi.start")(function* () {
yield* workspace yield* workspace
@@ -45,7 +46,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
last: events.at(-1)?.seq, last: events.at(-1)?.seq,
directory: ctx.payload.directory, directory: ctx.payload.directory,
}) })
SyncEvent.replayAll(events) yield* sync.replayAll(events)
log.info("sync replay complete", { log.info("sync replay complete", {
sessionID: source, sessionID: source,
events: events.length, events: events.length,
@@ -1,13 +1,6 @@
import { ProxyUtil } from "@/server/proxy-util" import { ProxyUtil } from "@/server/proxy-util"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
FetchHttpClient,
HttpBody,
HttpClient,
HttpClientRequest,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
@@ -66,12 +59,13 @@ function statusText(response: unknown) {
} }
export function http( export function http(
client: HttpClient.HttpClient,
url: string | URL, url: string | URL,
extra: HeadersInit | undefined, extra: HeadersInit | undefined,
request: HttpServerRequest.HttpServerRequest, request: HttpServerRequest.HttpServerRequest,
): Effect.Effect<HttpServerResponse.HttpServerResponse> { ): Effect.Effect<HttpServerResponse.HttpServerResponse> {
return Effect.gen(function* () { return Effect.gen(function* () {
const response = yield* HttpClient.execute( const response = yield* client.execute(
HttpClientRequest.make(request.method as never)(url, { HttpClientRequest.make(request.method as never)(url, {
headers: ProxyUtil.headers(request.headers as HeadersInit, extra), headers: ProxyUtil.headers(request.headers as HeadersInit, extra),
body: requestBody(request), body: requestBody(request),
@@ -86,10 +80,7 @@ export function http(
statusText: statusText(response), statusText: statusText(response),
headers, headers,
}) })
}).pipe( }).pipe(Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))))
Effect.provide(FetchHttpClient.layer),
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))),
)
} }
export * as HttpApiProxy from "./proxy" export * as HttpApiProxy from "./proxy"
@@ -9,7 +9,7 @@ import * as Fence from "@/server/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect" import { Context, Data, Effect, Layer } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
@@ -95,6 +95,7 @@ function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
} }
function proxyRemote( function proxyRemote(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest, request: HttpServerRequest.HttpServerRequest,
workspace: Workspace.Info, workspace: Workspace.Info,
target: RemoteTarget, target: RemoteTarget,
@@ -111,7 +112,7 @@ function proxyRemote(
const proxyURL = workspaceProxyURL(target.url, url) const proxyURL = workspaceProxyURL(target.url, url)
const headers = request.headers as Record<string, string> const headers = request.headers as Record<string, string>
if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL)
const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) const response = yield* HttpApiProxy.http(client, proxyURL, target.headers, request)
const sync = Fence.parse(new Headers(response.headers)) const sync = Fence.parse(new Headers(response.headers))
if (sync) { if (sync) {
const syncFailure = yield* Fence.waitEffect( const syncFailure = yield* Fence.waitEffect(
@@ -163,18 +164,20 @@ function planRequest(
} }
function routeWorkspace<E>( function routeWorkspace<E>(
client: HttpClient.HttpClient,
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>, effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
plan: RequestPlan, plan: RequestPlan,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> { ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
return RequestPlan.$match(plan, { return RequestPlan.$match(plan, {
MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)),
Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url),
Local: ({ directory, workspaceID }) => Local: ({ directory, workspaceID }) =>
effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))),
}) })
} }
function routeHttpApiWorkspace<E>( function routeHttpApiWorkspace<E>(
client: HttpClient.HttpClient,
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>, effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
): Effect.Effect< ): Effect.Effect<
HttpServerResponse.HttpServerResponse, HttpServerResponse.HttpServerResponse,
@@ -188,7 +191,7 @@ function routeHttpApiWorkspace<E>(
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
: undefined : undefined
const plan = yield* planRequest(request, session?.workspaceID) const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(effect, plan) return yield* routeWorkspace(client, effect, plan)
}) })
} }
@@ -197,8 +200,9 @@ export const workspaceRoutingLayer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return WorkspaceRoutingMiddleware.of((effect) => return WorkspaceRoutingMiddleware.of((effect) =>
routeHttpApiWorkspace(effect).pipe( routeHttpApiWorkspace(client, effect).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace), Effect.provideService(Workspace.Service, workspace),
), ),
@@ -210,11 +214,12 @@ export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: Works
Effect.gen(function* () { Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return (effect) => return (effect) =>
Effect.gen(function* () { Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest const request = yield* HttpServerRequest.HttpServerRequest
const plan = yield* planRequest(request) const plan = yield* planRequest(request)
return yield* routeWorkspace(effect, plan) return yield* routeWorkspace(client, effect, plan)
}).pipe( }).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace), Effect.provideService(Workspace.Service, workspace),
@@ -1,7 +1,8 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Account } from "@/account/account" import { Account } from "@/account/account"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Auth } from "@/auth" import { Auth } from "@/auth"
@@ -31,15 +32,17 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { Skill } from "@/skill" import { Skill } from "@/skill"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin } from "@/server/cors" import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/routes/ui"
import { InstanceHttpApi, RootHttpApi } from "./api" import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { eventRoute } from "./event" import { EventApi, eventHandlers } from "./event"
import { configHandlers } from "./handlers/config" import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control" import { controlHandlers } from "./handlers/control"
import { experimentalHandlers } from "./handlers/experimental" import { experimentalHandlers } from "./handlers/experimental"
@@ -74,15 +77,24 @@ const runtime = HttpRouter.middleware()(
), ),
).layer ).layer
const cors = HttpRouter.middleware( const cors = (corsOptions?: CorsOptions) =>
HttpRouter.middleware(
HttpMiddleware.cors({ HttpMiddleware.cors({
allowedOrigins: isAllowedCorsOrigin, allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions),
maxAge: 86_400, maxAge: 86_400,
}), }),
{ global: true }, { global: true },
) )
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) 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( const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([ Layer.provide([
configHandlers, configHandlers,
@@ -102,14 +114,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
]), ]),
) )
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
Layer.provide(
authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)),
),
)
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([ Layer.provide([
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
@@ -118,9 +123,18 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
]), ]),
) )
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( 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 function createRoutes(corsOptions?: CorsOptions) {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
Layer.provide([ Layer.provide([
cors, cors(corsOptions),
runtime, runtime,
Account.defaultLayer, Account.defaultLayer,
Agent.defaultLayer, Agent.defaultLayer,
@@ -147,6 +161,7 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
SessionRunState.defaultLayer, SessionRunState.defaultLayer,
SessionStatus.defaultLayer, SessionStatus.defaultLayer,
SessionSummary.defaultLayer, SessionSummary.defaultLayer,
SyncEvent.defaultLayer,
Skill.defaultLayer, Skill.defaultLayer,
Todo.defaultLayer, Todo.defaultLayer,
ToolRegistry.defaultLayer, ToolRegistry.defaultLayer,
@@ -154,16 +169,30 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Workspace.defaultLayer, Workspace.defaultLayer,
Worktree.defaultLayer, Worktree.defaultLayer,
Bus.layer, Bus.layer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices, HttpServer.layerServices,
]), ]),
Layer.provideMerge(Observability.layer), Layer.provideMerge(Observability.layer),
) )
}
export const webHandler = lazy(() => export const routes = createRoutes()
const defaultWebHandler = lazy(() =>
HttpRouter.toWebHandler(routes, { HttpRouter.toWebHandler(routes, {
memoMap, memoMap,
middleware: disposeMiddleware, 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" export * as ExperimentalHttpApiServer from "./server"
@@ -78,18 +78,22 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const query = c.req.valid("query") const query = c.req.valid("query")
const sessions: Session.Info[] = [] return c.json(
for await (const session of Session.list({ await runRequest(
"SessionRoutes.list",
c,
Session.Service.use((svc) =>
svc.list({
directory: query.scope === "project" ? undefined : query.directory, directory: query.scope === "project" ? undefined : query.directory,
path: query.path, path: query.path,
roots: queryBoolean(query.roots), roots: queryBoolean(query.roots),
start: query.start, start: query.start,
search: query.search, search: query.search,
limit: query.limit, limit: query.limit,
})) { }),
sessions.push(session) ),
} ),
return c.json(sessions) )
}, },
) )
.get( .get(
@@ -94,7 +94,7 @@ export const SyncRoutes = lazy(() =>
last: events.at(-1)?.seq, last: events.at(-1)?.seq,
directory: body.directory, directory: body.directory,
}) })
SyncEvent.replayAll(events) await AppRuntime.runPromise(SyncEvent.use.replayAll(events))
log.info("sync replay complete", { log.info("sync replay complete", {
sessionID: source, sessionID: source,
+93 -21
View File
@@ -1,9 +1,13 @@
import { Flag } from "@opencode-ai/core/flag/flag" 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 { Hono } from "hono"
import { proxy } from "hono/proxy" import { proxy } from "hono/proxy"
import { getMimeType } from "hono/utils/mime" import { getMimeType } from "hono/utils/mime"
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import { ProxyUtil } from "../proxy-util"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null) ? Promise.resolve(null)
@@ -12,44 +16,112 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
const DEFAULT_CSP = 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:" "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 = "") => 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:` `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:`
export const UIRoutes = (): Hono => function themePreloadHash(body: string) {
new Hono().all("/*", async (c) => { return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
const embeddedWebUI = await embeddedUIPromise }
const path = c.req.path
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) { if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404) if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
if (await fs.exists(match)) { if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain" const mime = getMimeType(match) ?? "text/plain"
c.header("Content-Type", mime) const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) { if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
c.header("Content-Security-Policy", DEFAULT_CSP) return new Response(new Uint8Array(await fs.readFile(match)), { headers })
} }
return c.body(new Uint8Array(await fs.readFile(match)))
} else { return Response.json({ error: "Not Found" }, { status: 404 })
return c.json({ error: "Not Found" }, 404)
} }
} else {
const response = await proxy(`https://app.opencode.ai${path}`, { const response = await proxy(upstreamURL(path), {
raw: c.req.raw, raw: request,
headers: { headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }),
...Object.fromEntries(c.req.raw.headers.entries()),
host: "app.opencode.ai",
},
}) })
const match = response.headers.get("content-type")?.includes("text/html") const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match( ? themePreloadHash(await response.clone().text())
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined : undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash)) response.headers.set("Content-Security-Policy", csp(hash))
return response 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
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
if (yield* services.fs.existsSafe(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 })
} }
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
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))
+15 -16
View File
@@ -18,6 +18,7 @@ import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace" import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import * as ServerBackend from "./backend" 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 // @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 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -38,6 +39,13 @@ type ServerApp = {
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response> request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
} }
type ListenOptions = CorsOptions & {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
}
const DefaultHono = lazy(() => const DefaultHono = lazy(() =>
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
) )
@@ -54,14 +62,14 @@ export const Default = () => {
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
} }
function create(opts: { cors?: string[] }) { function create(opts: ListenOptions) {
const selected = select() const selected = select()
return selected.backend === "effect-httpapi" return selected.backend === "effect-httpapi"
? withBackend(selected, createHttpApi()) ? withBackend(selected, createHttpApi(opts))
: withBackend(selected, createHono(opts, selected)) : withBackend(selected, createHono(opts, selected))
} }
export function Legacy(opts: { cors?: string[] } = {}) { export function Legacy(opts: CorsOptions = {}) {
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
} }
@@ -74,8 +82,8 @@ function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection:
return built return built
} }
function createHttpApi() { function createHttpApi(corsOptions?: CorsOptions) {
const handler = ExperimentalHttpApiServer.webHandler().handler const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
const app: ServerApp = { const app: ServerApp = {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input, init) { request(input, init) {
@@ -88,10 +96,7 @@ function createHttpApi() {
} }
} }
function createHono( function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
opts: { cors?: string[] },
selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"),
) {
const backendAttributes = ServerBackend.attributes(selection) const backendAttributes = ServerBackend.attributes(selection)
const app = new Hono() const app = new Hono()
.onError(ErrorMiddleware) .onError(ErrorMiddleware)
@@ -151,13 +156,7 @@ export async function openapi() {
export let url: URL export let url: URL
export async function listen(opts: { export async function listen(opts: ListenOptions): Promise<Listener> {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts) const built = create(opts)
const server = await built.runtime.listen(opts) const server = await built.runtime.listen(opts)
+13 -10
View File
@@ -3,11 +3,11 @@ import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Record } from "effect" import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep, pipe } from "remeda" import { mergeDeep } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Instance } from "@/project/instance" import { InstanceState } from "@/effect/instance-state"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
@@ -29,6 +29,10 @@ const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>> 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 = { export type StreamInput = {
user: MessageV2.User user: MessageV2.User
sessionID: string sessionID: string
@@ -134,12 +138,7 @@ const live: Layer.Layer<
sessionID: input.sessionID, sessionID: input.sessionID,
providerOptions: item.options, providerOptions: item.options,
}) })
const options: Record<string, any> = pipe( const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) { if (isOpenaiOauth) {
options.instructions = system.join("\n") options.instructions = system.join("\n")
} }
@@ -268,7 +267,7 @@ const live: Layer.Layer<
const bridge = yield* EffectBridge.make() const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>() const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session // Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools) // (prevents infinite approval loops for server-side MCP tools)
@@ -330,6 +329,10 @@ const live: Layer.Layer<
}) })
: undefined : undefined
const opencodeProjectID = input.model.providerID.startsWith("opencode")
? (yield* InstanceState.context).project.id
: undefined
return streamText({ return streamText({
onError(error) { onError(error) {
l.error("stream error", { l.error("stream error", {
@@ -369,7 +372,7 @@ const live: Layer.Layer<
headers: { headers: {
...(input.model.providerID.startsWith("opencode") ...(input.model.providerID.startsWith("opencode")
? { ? {
"x-opencode-project": Instance.project.id, "x-opencode-project": opencodeProjectID,
"x-opencode-session": input.sessionID, "x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id, "x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT, "x-opencode-client": Flag.OPENCODE_CLIENT,
+15 -24
View File
@@ -45,7 +45,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url" import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema } from "effect" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { withStatics } from "@/util/schema"
import * as EffectLogger from "@opencode-ai/core/effect/logger" 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 resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const parts: PromptInput["parts"] = [{ type: "text", text: template }] const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template) const files = ConfigMarkdown.files(template)
const seen = new Set<string>() const seen = new Set<string>()
yield* Effect.forEach( yield* Effect.forEach(
@@ -256,7 +256,8 @@ export const layer = Layer.effect(
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const plan = Session.plan(input.session) const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
if (!(yield* fsys.existsSafe(plan))) return input.messages if (!(yield* fsys.existsSafe(plan))) return input.messages
const part = yield* sessions.updatePart({ const part = yield* sessions.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
@@ -272,7 +273,8 @@ export const layer = Layer.effect(
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
const plan = Session.plan(input.session) const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan) const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die)) if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
const part = yield* sessions.updatePart({ const part = yield* sessions.updatePart({
@@ -1012,7 +1014,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
case "file:": { case "file:": {
log.info("file", { mime: part.mime }) log.info("file", { mime: part.mime })
const filepath = fileURLToPath(part.url) const filepath = fileURLToPath(part.url)
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory" const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
const { read } = yield* registry.named() const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => { const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
@@ -1031,7 +1033,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
} }
if (part.mime === "text/plain") { if (mime === "text/plain") {
let offset: number | undefined let offset: number | undefined
let limit: number | undefined let limit: number | undefined
const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
@@ -1089,7 +1091,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})), })),
) )
} else { } else {
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })
} }
} else { } else {
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
@@ -1110,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return pieces return pieces
} }
if (part.mime === "application/x-directory") { if (mime === "application/x-directory") {
const args = { filePath: filepath } const args = { filePath: filepath }
const exit = yield* execRead(args).pipe(Effect.exit) const exit = yield* execRead(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
@@ -1146,7 +1148,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
synthetic: true, synthetic: true,
text: exit.value.output, text: exit.value.output,
}, },
{ ...part, messageID: info.id, sessionID: input.sessionID }, { ...part, mime, messageID: info.id, sessionID: input.sessionID },
] ]
} }
@@ -1164,9 +1166,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: input.sessionID, sessionID: input.sessionID,
type: "file", type: "file",
url: url:
`data:${part.mime};base64,` + `data:${mime};base64,` +
Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
mime: part.mime, mime,
filename: part.filename!, filename: part.filename!,
source: part.source, source: part.source,
}, },
@@ -1441,7 +1443,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([ const [skills, env, instructions, modelMsgs] = yield* Effect.all([
sys.skills(agent), sys.skills(agent),
Effect.sync(() => sys.environment(model)), sys.environment(model),
instruction.system().pipe(Effect.orDie), instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model), MessageV2.toModelMessagesEffect(msgs, model),
]) ])
@@ -1700,18 +1702,7 @@ export const PromptInput = Schema.Struct({
]).annotate({ discriminator: "type" }), ]).annotate({ discriminator: "type" }),
), ),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
// `z.discriminatedUnion` erases the discriminated members' shapes back to export type PromptInput = Schema.Schema.Type<typeof PromptInput>
// `{}` 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")({ export class LoopInput extends Schema.Class<LoopInput>("SessionPrompt.LoopInput")({
sessionID: SessionID, sessionID: SessionID,
+4 -2
View File
@@ -38,6 +38,7 @@ export const layer = Layer.effect(
const bus = yield* Bus.Service const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service const state = yield* SessionRunState.Service
const sync = yield* SyncEvent.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID) yield* state.assertNotBusy(input.sessionID)
@@ -121,7 +122,7 @@ export const layer = Layer.effect(
remove.push(msg) remove.push(msg)
} }
for (const msg of remove) { for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, { yield* sync.run(MessageV2.Event.Removed, {
sessionID, sessionID,
messageID: msg.info.id, messageID: msg.info.id,
}) })
@@ -133,7 +134,7 @@ export const layer = Layer.effect(
const removeParts = target.parts.slice(idx) const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx) target.parts = target.parts.slice(0, idx)
for (const part of removeParts) { for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, { yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID, sessionID,
messageID: target.info.id, messageID: target.info.id,
partID: part.id, partID: part.id,
@@ -156,6 +157,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Storage.defaultLayer), Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
), ),
) )
+53 -46
View File
@@ -26,7 +26,7 @@ import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance" import type { InstanceContext } from "../project/instance"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema" import { ProjectID } from "../project/schema"
@@ -234,6 +234,16 @@ export const MessagesInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
limit: Schema.optional(NonNegativeInt), limit: Schema.optional(NonNegativeInt),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ListInput = {
directory?: string
scope?: "project"
path?: string
workspaceID?: WorkspaceID
roots?: boolean
start?: number
search?: string
limit?: number
}
const CreatedEventSchema = Schema.Struct({ const CreatedEventSchema = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
@@ -311,9 +321,9 @@ export const Event = {
), ),
} }
export function plan(input: { slug: string; time: { created: number } }) { export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) {
const base = Instance.project.vcs const base = instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans") ? path.join(instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans") : path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md") return path.join(base, [input.time.created, input.slug].join("-") + ".md")
} }
@@ -390,6 +400,7 @@ export class BusyError extends Error {
} }
export interface Interface { export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: { readonly create: (input?: {
parentID?: SessionID parentID?: SessionID
title?: string title?: string
@@ -443,11 +454,12 @@ export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["dat
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) => const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn)) Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service | SyncEvent.Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const storage = yield* Storage.Service const storage = yield* Storage.Service
const sync = yield* SyncEvent.Service
const createNext = Effect.fn("Session.createNext")(function* (input: { const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID id?: SessionID
@@ -477,7 +489,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
} }
log.info("created", result) log.info("created", result)
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) yield* sync.run(Event.Created, { sessionID: result.id, info: result })
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be // This only exist for backwards compatibility. We should not be
@@ -497,6 +509,11 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return fromRow(row) return fromRow(row)
}) })
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) }))
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const rows = yield* db((d) => const rows = yield* db((d) =>
d d
@@ -525,10 +542,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
Effect.catchCause(() => Effect.succeed(false)), Effect.catchCause(() => Effect.succeed(false)),
) )
yield* Effect.sync(() => { yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) yield* sync.remove(sessionID)
SyncEvent.remove(sessionID)
})
} catch (e) { } catch (e) {
log.error(e) log.error(e)
} }
@@ -536,19 +551,17 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> => const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })) yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })
return msg return msg
}).pipe(Effect.withSpan("Session.updateMessage")) }).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> => const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.sync(() => yield* sync.run(MessageV2.Event.PartUpdated, {
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID, sessionID: part.sessionID,
part: structuredClone(part), part: structuredClone(part),
time: Date.now(), time: Date.now(),
}), })
)
return part return part
}).pipe(Effect.withSpan("Session.updatePart")) }).pipe(Effect.withSpan("Session.updatePart"))
@@ -635,8 +648,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return session return session
}) })
const patch = (sessionID: SessionID, info: Patch) => const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info })
Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info }))
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() } }) yield* patch(sessionID, { time: { updated: Date.now() } })
@@ -693,12 +705,10 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
sessionID: SessionID sessionID: SessionID
messageID: MessageID messageID: MessageID
}) { }) {
yield* Effect.sync(() => yield* sync.run(MessageV2.Event.Removed, {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.messageID, messageID: input.messageID,
}), })
)
return input.messageID return input.messageID
}) })
@@ -707,13 +717,11 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
messageID: MessageID messageID: MessageID
partID: PartID partID: PartID
}) { }) {
yield* Effect.sync(() => yield* sync.run(MessageV2.Event.PartRemoved, {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.messageID, messageID: input.messageID,
partID: input.partID, partID: input.partID,
}), })
)
return input.partID return input.partID
}) })
@@ -739,6 +747,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
}) })
return Service.of({ return Service.of({
list,
create, create,
fork, fork,
touch, touch,
@@ -764,25 +773,23 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
)
export function* list(input?: { function* listByProject(
directory?: string input: ListInput & {
scope?: "project" projectID: ProjectID
path?: string },
workspaceID?: WorkspaceID ) {
roots?: boolean const conditions = [eq(SessionTable.project_id, input.projectID)]
start?: number
search?: string
limit?: number
}) {
const project = Instance.project
const conditions = [eq(SessionTable.project_id, project.id)]
if (input?.workspaceID) { if (input.workspaceID) {
conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
} }
if (input?.path !== undefined) { if (input.path !== undefined) {
if (input.path) { if (input.path) {
const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
@@ -792,22 +799,22 @@ export function* list(input?: {
: or(...conds)!, : or(...conds)!,
) )
} }
} else if (input?.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { } else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (input?.directory) { if (input.directory) {
conditions.push(eq(SessionTable.directory, input.directory)) conditions.push(eq(SessionTable.directory, input.directory))
} }
} }
if (input?.roots) { if (input.roots) {
conditions.push(isNull(SessionTable.parent_id)) conditions.push(isNull(SessionTable.parent_id))
} }
if (input?.start) { if (input.start) {
conditions.push(gte(SessionTable.time_updated, input.start)) conditions.push(gte(SessionTable.time_updated, input.start))
} }
if (input?.search) { if (input.search) {
conditions.push(like(SessionTable.title, `%${input.search}%`)) conditions.push(like(SessionTable.title, `%${input.search}%`))
} }
const limit = input?.limit ?? 100 const limit = input.limit ?? 100
const rows = Database.use((db) => const rows = Database.use((db) =>
db db
+8 -8
View File
@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_DEFAULT from "./prompt/default.txt" import PROMPT_DEFAULT from "./prompt/default.txt"
@@ -33,7 +33,7 @@ export function provider(model: Provider.Model) {
} }
export interface Interface { export interface Interface {
readonly environment: (model: Provider.Model) => string[] readonly environment: (model: Provider.Model) => Effect.Effect<string[]>
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined> readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
} }
@@ -45,22 +45,22 @@ export const layer = Layer.effect(
const skill = yield* Skill.Service const skill = yield* Skill.Service
return Service.of({ return Service.of({
environment(model) { environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
const project = Instance.project const ctx = yield* InstanceState.context
return [ return [
[ [
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`, `Here is some useful information about the environment you are running in:`,
`<env>`, `<env>`,
` Working directory: ${Instance.directory}`, ` Working directory: ${ctx.directory}`,
` Workspace root folder: ${Instance.worktree}`, ` Workspace root folder: ${ctx.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`, ` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`, ` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`, ` Today's date: ${new Date().toDateString()}`,
`</env>`, `</env>`,
].join("\n"), ].join("\n"),
] ]
}, }),
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) { skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return if (Permission.disabled(["skill"], agent.permission).has("skill")) return
+4 -4
View File
@@ -21,20 +21,19 @@ export const layer = Layer.effect(
const session = yield* Session.Service const session = yield* Session.Service
const shareNext = yield* ShareNext.Service const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get() const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID) const result = yield* shareNext.create(sessionID)
yield* Effect.sync(() => yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } })
SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }),
)
return result return result
}) })
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID) yield* shareNext.remove(sessionID)
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })
}) })
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
@@ -54,6 +53,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(ShareNext.defaultLayer), Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
) )
export * as SessionShare from "./session" export * as SessionShare from "./session"
+8 -1
View File
@@ -48,6 +48,13 @@ type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[] type Journal = { sql: string; timestamp: number; name: string }[]
// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use.
const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void
function applyMigrations(db: SQLiteBunDatabase, entries: Journal) {
migrateFromJournal(db, entries)
}
function time(tag: string) { function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0 if (!match) return 0
@@ -108,7 +115,7 @@ export const Client = lazy(() => {
item.sql = "select 1;" item.sql = "select 1;"
} }
} }
migrate(db, entries) applyMigrations(db, entries)
} }
return db return db
+158 -84
View File
@@ -4,14 +4,17 @@ import { eq } from "drizzle-orm"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus" import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Instance } from "@/project/instance" import type { InstanceContext } from "@/project/instance"
import { EventSequenceTable, EventTable } from "./event.sql" import { EventSequenceTable, EventTable } from "./event.sql"
import { WorkspaceContext } from "@/control-plane/workspace-context" import type { WorkspaceID } from "@/control-plane/schema"
import { EventID } from "./schema" import { EventID } from "./schema"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Schema as EffectSchema } from "effect" import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import { zodObject } from "@/util/effect-zod" import { zodObject } from "@/util/effect-zod"
import type { DeepMutable } from "@/util/schema" import type { DeepMutable } from "@/util/schema"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
// Keep `Event["data"]` mutable because projectors mutate the persisted shape // Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly — // when writing to the database. Bus payloads (`Properties`) stay readonly —
@@ -45,6 +48,142 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown> type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
type PublishContext = {
instance?: InstanceContext
workspace?: WorkspaceID
}
export interface Interface {
readonly run: <Def extends Definition>(
def: Def,
data: Event<Def>["data"],
options?: { publish?: boolean },
) => Effect.Effect<void>
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SyncEvent") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
}
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
)
const latest = row?.seq ?? -1
if (event.seq <= latest) return
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(
`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`,
)
}
const publish = !!options?.publish
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
process(def, event, { publish, context })
})
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
yield* replay(item, options)
}
return source
})
const run: Interface["run"] = Effect.fn("SyncEvent.run")(function* (def, data, options) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in
// the definition
if (agg == null) {
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
}
if (def.version !== versions.get(def.type)) {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
const { publish = true } = options || {}
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
Database.transaction(
(tx) => {
const id = EventID.ascending()
const row = tx
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, agg))
.get()
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish, context })
},
{
behavior: "immediate",
},
)
})
const remove: Interface["remove"] = Effect.fn("SyncEvent.remove")(function* (aggregateID) {
Database.transaction((tx) => {
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
})
return Service.of({
run,
replay,
replayAll,
remove,
})
}),
)
export const defaultLayer = layer
export const use = serviceUse(Service)
const runtime = makeRuntime(Service, defaultLayer)
export const registry = new Map<string, Definition>() export const registry = new Map<string, Definition>()
let projectors: Map<Definition, ProjectorFunc> | undefined let projectors: Map<Definition, ProjectorFunc> | undefined
@@ -121,7 +260,11 @@ export function project<Def extends Definition>(
return [def, func as ProjectorFunc] return [def, func as ProjectorFunc]
} }
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) { function process<Def extends Definition>(
def: Def,
event: Event<Def>,
options: { publish: boolean; context?: PublishContext },
) {
if (projectors == null) { if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
} }
@@ -160,6 +303,10 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
Database.effect(() => { Database.effect(() => {
if (options?.publish) { if (options?.publish) {
if (!options.context?.instance) {
throw new Error("SyncEvent.process: publish requires instance context")
}
const result = convertEvent(def.type, event.data) const result = convertEvent(def.type, event.data)
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>) const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>)
if (result instanceof Promise) { if (result instanceof Promise) {
@@ -169,9 +316,9 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
} }
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: Instance.directory, directory: options.context.instance.directory,
project: Instance.project.id, project: options.context.instance.project.id,
workspace: WorkspaceContext.workspaceID, workspace: options.context.workspace,
payload: { payload: {
type: "sync", type: "sync",
syncEvent: { syncEvent: {
@@ -186,92 +333,19 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
} }
export function replay(event: SerializedEvent, options?: { publish: boolean }) { export function replay(event: SerializedEvent, options?: { publish: boolean }) {
const def = registry.get(event.type) return runtime.runSync((sync) => sync.replay(event, options))
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
}
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
)
const latest = row?.seq ?? -1
if (event.seq <= latest) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
}
process(def, event, { publish: !!options?.publish })
} }
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
const source = events[0]?.aggregateID return runtime.runSync((sync) => sync.replayAll(events, options))
if (!source) return
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
replay(item, options)
}
return source
} }
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) { export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
const agg = (data as Record<string, string>)[def.aggregate] return runtime.runSync((sync) => sync.run(def, data, options))
// This should never happen: we've enforced it via typescript in
// the definition
if (agg == null) {
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
}
if (def.version !== versions.get(def.type)) {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
const { publish = true } = options || {}
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
Database.transaction(
(tx) => {
const id = EventID.ascending()
const row = tx
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, agg))
.get()
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish })
},
{
behavior: "immediate",
},
)
} }
export function remove(aggregateID: string) { export function remove(aggregateID: string) {
Database.transaction((tx) => { return runtime.runSync((sync) => sync.remove(aggregateID))
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
} }
export function payloads() { export function payloads() {
+11 -9
View File
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
import * as Tool from "./tool" import * as Tool from "./tool"
import { Bus } from "../bus" import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { Patch } from "../patch" import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
@@ -52,6 +52,8 @@ export const ApplyPatchTool = Tool.define(
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found")) return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
} }
const instance = yield* InstanceState.context
// Validate file paths and check permissions // Validate file paths and check permissions
const fileChanges: Array<{ const fileChanges: Array<{
filePath: string filePath: string
@@ -68,7 +70,7 @@ export const ApplyPatchTool = Tool.define(
let totalDiff = "" let totalDiff = ""
for (const hunk of hunks) { for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path) const filePath = path.resolve(instance.directory, hunk.path)
yield* assertExternalDirectoryEffect(ctx, filePath) yield* assertExternalDirectoryEffect(ctx, filePath)
switch (hunk.type) { switch (hunk.type) {
@@ -133,7 +135,7 @@ export const ApplyPatchTool = Tool.define(
if (change.removed) deletions += change.count || 0 if (change.removed) deletions += change.count || 0
} }
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined
yield* assertExternalDirectoryEffect(ctx, movePath) yield* assertExternalDirectoryEffect(ctx, movePath)
fileChanges.push({ fileChanges.push({
@@ -187,7 +189,7 @@ export const ApplyPatchTool = Tool.define(
// Build per-file metadata for UI rendering (used for both permission and result) // Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({ const files = fileChanges.map((change) => ({
filePath: change.filePath, filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), relativePath: path.relative(instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type, type: change.type,
patch: change.diff, patch: change.diff,
additions: change.additions, additions: change.additions,
@@ -196,7 +198,7 @@ export const ApplyPatchTool = Tool.define(
})) }))
// Check permissions if needed // Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/"))
yield* ctx.ask({ yield* ctx.ask({
permission: "edit", permission: "edit",
patterns: relativePaths, patterns: relativePaths,
@@ -267,13 +269,13 @@ export const ApplyPatchTool = Tool.define(
// Generate output summary // Generate output summary
const summaryLines = fileChanges.map((change) => { const summaryLines = fileChanges.map((change) => {
if (change.type === "add") { if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` return `A ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}`
} }
if (change.type === "delete") { if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` return `D ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}`
} }
const target = change.movePath ?? change.filePath const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` return `M ${path.relative(instance.worktree, target).replaceAll("\\", "/")}`
}) })
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
@@ -282,7 +284,7 @@ export const ApplyPatchTool = Tool.define(
const target = change.movePath ?? change.filePath const target = change.movePath ?? change.filePath
const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? []) const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
if (!block) continue if (!block) continue
const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/") const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}` output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
} }
+16 -7
View File
@@ -6,7 +6,7 @@ import * as Tool from "./tool"
import path from "path" import path from "path"
import DESCRIPTION from "./bash.txt" import DESCRIPTION from "./bash.txt"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../project/instance" import { Instance, type InstanceContext } from "../project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter" import { Language, type Node } from "web-tree-sitter"
@@ -14,6 +14,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
@@ -363,7 +364,13 @@ export const BashTool = Tool.define(
return yield* resolvePath(next, cwd, shell) return yield* resolvePath(next, cwd, shell)
}) })
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { const collect = Effect.fn("BashTool.collect")(function* (
root: Node,
cwd: string,
ps: boolean,
shell: string,
instance: InstanceContext,
) {
const scan: Scan = { const scan: Scan = {
dirs: new Set<string>(), dirs: new Set<string>(),
patterns: new Set<string>(), patterns: new Set<string>(),
@@ -379,7 +386,7 @@ export const BashTool = Tool.define(
for (const arg of pathArgs(command, ps)) { for (const arg of pathArgs(command, ps)) {
const resolved = yield* argPath(arg, cwd, ps, shell) const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved }) log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue if (!resolved || Instance.containsPath(resolved, instance)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir) scan.dirs.add(dir)
} }
@@ -581,6 +588,7 @@ export const BashTool = Tool.define(
return { return {
description: DESCRIPTION.replaceAll("${directory}", instance.directory) description: DESCRIPTION.replaceAll("${directory}", instance.directory)
.replaceAll("${tmp}", Global.Path.tmp)
.replaceAll("${os}", process.platform) .replaceAll("${os}", process.platform)
.replaceAll("${shell}", name) .replaceAll("${shell}", name)
.replaceAll("${chaining}", chain) .replaceAll("${chaining}", chain)
@@ -589,9 +597,10 @@ export const BashTool = Tool.define(
parameters: Parameters, parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const executeInstance = yield* InstanceState.context
const cwd = params.workdir const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell) ? yield* resolvePath(params.workdir, executeInstance.directory, shell)
: Instance.directory : executeInstance.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
} }
@@ -602,8 +611,8 @@ export const BashTool = Tool.define(
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()), Effect.sync(() => tree.delete()),
) )
const scan = yield* collect(tree.rootNode, cwd, ps, shell) const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
yield* ask(ctx, scan) yield* ask(ctx, scan)
}), }),
) )
+2
View File
@@ -4,6 +4,8 @@ Be aware: OS: ${os}, Shell: ${shell}
All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead. All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
Before executing the command, please follow these steps: Before executing the command, please follow these steps:
+6 -5
View File
@@ -13,7 +13,7 @@ import { File } from "../file"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Format } from "../format" import { Format } from "../format"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -76,9 +76,10 @@ export const EditTool = Tool.define(
throw new Error("No changes to apply: oldString and newString are identical.") throw new Error("No changes to apply: oldString and newString are identical.")
} }
const instance = yield* InstanceState.context
const filePath = path.isAbsolute(params.filePath) const filePath = path.isAbsolute(params.filePath)
? params.filePath ? params.filePath
: path.join(Instance.directory, params.filePath) : path.join(instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath) yield* assertExternalDirectoryEffect(ctx, filePath)
let diff = "" let diff = ""
@@ -96,7 +97,7 @@ export const EditTool = Tool.define(
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({ yield* ctx.ask({
permission: "edit", permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)], patterns: [path.relative(instance.worktree, filePath)],
always: ["*"], always: ["*"],
metadata: { metadata: {
filepath: filePath, filepath: filePath,
@@ -139,7 +140,7 @@ export const EditTool = Tool.define(
) )
yield* ctx.ask({ yield* ctx.ask({
permission: "edit", permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)], patterns: [path.relative(instance.worktree, filePath)],
always: ["*"], always: ["*"],
metadata: { metadata: {
filepath: filePath, filepath: filePath,
@@ -201,7 +202,7 @@ export const EditTool = Tool.define(
diff, diff,
filediff, filediff,
}, },
title: `${path.relative(Instance.worktree, filePath)}`, title: `${path.relative(instance.worktree, filePath)}`,
output, output,
} }
}), }),
+4 -3
View File
@@ -3,7 +3,7 @@ import * as Tool from "./tool"
import path from "path" import path from "path"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import DESCRIPTION from "./lsp.txt" import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -44,7 +44,8 @@ export const LspTool = Tool.define(
parameters: Parameters, parameters: Parameters,
execute: (args: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => execute: (args: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) const instance = yield* InstanceState.context
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file) yield* assertExternalDirectoryEffect(ctx, file)
const meta = const meta =
args.operation === "workspaceSymbol" args.operation === "workspaceSymbol"
@@ -61,7 +62,7 @@ export const LspTool = Tool.define(
const uri = pathToFileURL(file).href const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 } const position = { file, line: args.line - 1, character: args.character - 1 }
const relPath = path.relative(Instance.worktree, file) const relPath = path.relative(instance.worktree, file)
const detail = const detail =
args.operation === "workspaceSymbol" args.operation === "workspaceSymbol"
? "" ? ""
+3 -2
View File
@@ -5,7 +5,7 @@ import { Question } from "../question"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { type SessionID, MessageID, PartID } from "../session/schema" import { type SessionID, MessageID, PartID } from "../session/schema"
import EXIT_DESCRIPTION from "./plan-exit.txt" import EXIT_DESCRIPTION from "./plan-exit.txt"
@@ -30,8 +30,9 @@ export const PlanExitTool = Tool.define(
parameters: Parameters, parameters: Parameters,
execute: (_params: {}, ctx: Tool.Context) => execute: (_params: {}, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceState.context
const info = yield* session.get(ctx.sessionID) const info = yield* session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(info)) const plan = path.relative(instance.worktree, Session.plan(info, instance))
const answers = yield* question.ask({ const answers = yield* question.ask({
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
questions: [ questions: [
+4 -3
View File
@@ -7,7 +7,7 @@ import * as Tool from "./tool"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
@@ -157,14 +157,15 @@ export const ReadTool = Tool.define(
return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
} }
const instance = yield* InstanceState.context
let filepath = params.filePath let filepath = params.filePath
if (!path.isAbsolute(filepath)) { if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath) filepath = path.resolve(instance.directory, filepath)
} }
if (process.platform === "win32") { if (process.platform === "win32") {
filepath = AppFileSystem.normalizePath(filepath) filepath = AppFileSystem.normalizePath(filepath)
} }
const title = path.relative(Instance.worktree, filepath) const title = path.relative(instance.worktree, filepath)
const stat = yield* fs.stat(filepath).pipe( const stat = yield* fs.stat(filepath).pipe(
Effect.catchIf( Effect.catchIf(
+5 -4
View File
@@ -10,7 +10,7 @@ import { File } from "../file"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Format } from "../format" import { Format } from "../format"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { trimDiff } from "./edit" import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import * as Bom from "@/util/bom" import * as Bom from "@/util/bom"
@@ -37,9 +37,10 @@ export const WriteTool = Tool.define(
parameters: Parameters, parameters: Parameters,
execute: (params: { content: string; filePath: string }, ctx: Tool.Context) => execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceState.context
const filepath = path.isAbsolute(params.filePath) const filepath = path.isAbsolute(params.filePath)
? params.filePath ? params.filePath
: path.join(Instance.directory, params.filePath) : path.join(instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filepath) yield* assertExternalDirectoryEffect(ctx, filepath)
const exists = yield* fs.existsSafe(filepath) const exists = yield* fs.existsSafe(filepath)
@@ -52,7 +53,7 @@ export const WriteTool = Tool.define(
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
yield* ctx.ask({ yield* ctx.ask({
permission: "edit", permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)], patterns: [path.relative(instance.worktree, filepath)],
always: ["*"], always: ["*"],
metadata: { metadata: {
filepath, filepath,
@@ -89,7 +90,7 @@ export const WriteTool = Tool.define(
} }
return { return {
title: path.relative(Instance.worktree, filepath), title: path.relative(instance.worktree, filepath),
metadata: { metadata: {
diagnostics, diagnostics,
filepath, filepath,
@@ -1,7 +1,9 @@
import { Database } from "bun:sqlite" import { Database } from "bun:sqlite"
import { mkdir, symlink } from "node:fs/promises"
import os from "node:os"
import path from "node:path" import path from "node:path"
import { expect, test } from "bun:test" import { expect, spyOn, test } from "bun:test"
import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" import { offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
type ZedFixtureOptions = { type ZedFixtureOptions = {
@@ -66,6 +68,23 @@ test("offsetToPosition converts Zed offsets to 1-based editor positions", () =>
}) })
}) })
test("resolveZedDbPath skips candidates that cannot be stated", async () => {
await using tmp = await tmpdir()
const loop = path.join(tmp.path, "loop")
await symlink(loop, loop)
const home = spyOn(os, "homedir").mockImplementation(() => tmp.path)
const previous = process.env.OPENCODE_ZED_DB
process.env.OPENCODE_ZED_DB = loop
try {
expect(resolveZedDbPath()).toBeUndefined()
} finally {
if (previous === undefined) delete process.env.OPENCODE_ZED_DB
else process.env.OPENCODE_ZED_DB = previous
home.mockRestore()
}
})
test("resolveZedSelection returns active editor selection", async () => { test("resolveZedSelection returns active editor selection", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path) const fixture = await writeZedFixture(tmp.path)
@@ -251,6 +270,71 @@ test("resolveZedSelection returns empty when no workspace matches", async () =>
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
}) })
test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
],
},
})
})
test("resolveZedSelection prefers the most specific containing Zed workspace", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
const child = path.join(tmp.path, "packages")
const childFile = path.join(child, "child.ts")
await mkdir(child, { recursive: true })
await Bun.write(childFile, "child")
const db = new Database(fixture.dbPath)
db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"])
db.run("insert into panes values (2, 2, 1)")
db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"])
db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"])
db.run("insert into editor_selections values (2, 2, 0, 5)")
db.close()
expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({
type: "selection",
selection: {
filePath: childFile,
source: "zed",
ranges: [
{
text: "child",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 6 },
},
},
],
},
})
})
test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => {
await using tmp = await tmpdir()
const child = path.join(tmp.path, "effect-lab")
await mkdir(child, { recursive: true })
const fixture = await writeZedFixture(child)
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
})
test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => { test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false }) const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import Http from "node:http" import Http from "node:http"
import path from "node:path" import path from "node:path"
@@ -1426,48 +1426,41 @@ describe("workspace-old sessionRestore", () => {
}) })
}) })
test("local restore replays batches without fetch and emits progress", async () => { it.live("local restore replays batches and emits progress", () =>
await withInstance(async (dir) => { provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents() const captured = captureGlobalEvents()
let fetchCallCount = 0
const replayAll = spyOn(SyncEvent, "replayAll")
try { try {
using server = Bun.serve({
port: 0,
fetch() {
fetchCallCount++
return Response.json({ ok: true })
},
})
const type = unique("restore-local") const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir }) const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info) insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = await AppRuntime.runPromise( const session = yield* sessionSvc.create({ title: "restore local" })
SessionNs.Service.use((svc) => svc.create({ title: "restore local" })),
)
replaceSessionEvents(session.id, 20) replaceSessionEvents(session.id, 20)
expect(await restoreWorkspaceSession({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 }) expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
expect(fetchCallCount).toBe(0) })
expect(replayAll).toHaveBeenCalledTimes(3) expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1])
expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(
info.id,
)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect( expect(
captured.events captured.events
.filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type) .filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step), .map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3]) ).toEqual([0, 1, 2, 3])
await removeWorkspace(info.id) yield* workspace.remove(info.id)
} finally { } finally {
captured.dispose() captured.dispose()
} }
}) }),
}) { git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => { it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = [] const replay: FetchCall[] = []
+14 -3
View File
@@ -263,14 +263,25 @@ describe("Runner", () => {
Effect.gen(function* () { Effect.gen(function* () {
const s = yield* Scope.Scope const s = yield* Scope.Scope
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) const started = yield* Deferred.make<void>()
yield* Effect.sleep("10 millis") const fiber = yield* runner
.ensureRunning(
Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
return yield* Effect.never.pipe(Effect.as("x"))
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started).pipe(Effect.timeout("250 millis"))
yield* Effect.gen(function* () {
while (runner.state._tag !== "Running") yield* Effect.yieldNow
}).pipe(Effect.timeout("250 millis"))
const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel yield* runner.cancel
yield* Fiber.await(fiber) yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
}), }),
) )
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Config, Effect, Layer } from "effect" import { Config, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
@@ -61,4 +62,28 @@ describe("HttpApi CORS", () => {
expect(response.headers["access-control-allow-headers"]).toBe("authorization") expect(response.headers["access-control-allow-headers"]).toBe("authorization")
}), }),
) )
it.live("uses custom CORS origins passed to the server", () =>
Effect.gen(function* () {
const listener = yield* Effect.acquireRelease(
Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })),
(listener) => Effect.promise(() => listener.stop(true)),
)
const response = yield* Effect.promise(() =>
fetch(new URL(InstancePaths.path, listener.url), {
method: "OPTIONS",
headers: {
origin: "https://custom.example",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}),
)
expect(response.status).toBe(204)
expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example")
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
}),
)
}) })
@@ -5,6 +5,11 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@@ -89,6 +94,89 @@ afterEach(async () => {
}) })
describe("HttpApi JSON parity", () => { describe("HttpApi JSON parity", () => {
it.live(
"matches legacy JSON shape for safe GET endpoints",
withTmp(
{
git: true,
config: {
formatter: false,
lsp: false,
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
const headers = { "x-opencode-directory": tmp.path }
const legacy = app(false)
const httpapi = app(true)
yield* Effect.forEach(
[
{ label: "global.health", path: GlobalPaths.health, headers: {} },
{ label: "global.config", path: GlobalPaths.config, headers: {} },
{ label: "instance.path", path: InstancePaths.path, headers },
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
{ label: "instance.command", path: InstancePaths.command, headers },
{ label: "instance.agent", path: InstancePaths.agent, headers },
{ label: "instance.skill", path: InstancePaths.skill, headers },
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
{ label: "config.get", path: "/config", headers },
{ label: "config.providers", path: "/config/providers", headers },
{ label: "project.list", path: "/project", headers },
{ label: "project.current", path: "/project/current", headers },
{ label: "provider.list", path: "/provider", headers },
{ label: "provider.auth", path: "/provider/auth", headers },
{ label: "permission.list", path: "/permission", headers },
{ label: "question.list", path: "/question", headers },
{ label: "mcp.status", path: McpPaths.status, headers },
{ label: "pty.shells", path: PtyPaths.shells, headers },
{ label: "pty.list", path: PtyPaths.list, headers },
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
{
label: "file.content",
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
headers,
},
{ label: "file.status", path: FilePaths.status, headers },
{
label: "find.file",
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
headers,
},
{
label: "find.text",
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
headers,
},
{
label: "find.symbol",
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
headers,
},
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
{ label: "experimental.resource", path: ExperimentalPaths.resource, headers },
],
(input) => expectJsonParity({ ...input, legacy, httpapi }),
{ concurrency: 1 },
)
}),
),
)
it.live( it.live(
"matches legacy JSON shape for session read endpoints", "matches legacy JSON shape for session read endpoints",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
@@ -0,0 +1,245 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Effect, Layer } from "effect"
import {
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import {
ServerAuthConfig,
authorizationRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { serveUIEffect } from "../../src/server/routes/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
})
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
return
}
process.env[key] = value
}
function app(input?: { password?: string; username?: string }) {
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
ExperimentalHttpApiServer.context,
)
},
}
}
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
const handler = HttpRouter.toWebHandler(
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))),
Layer.provide([
AppFileSystem.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
HttpServer.layerServices,
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
]),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
ExperimentalHttpApiServer.context,
)
},
}
}
function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
onRequest?.(request)
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
}),
)
}
describe("HttpApi UI fallback", () => {
test("serves the web UI through the experimental backend", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
const response = await uiApp({
client: httpClient(
new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
(request) => {
proxiedUrl = request.url
},
),
}).request("/")
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/html")
expect(await response.text()).toBe("<html>opencode</html>")
expect(proxiedUrl).toBe("https://app.opencode.ai/")
})
test("strips upstream transfer encoding headers from proxied assets", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
fs,
client,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
AppFileSystem.defaultLayer,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
proxiedUrl = request.url
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("console.log('ok')", {
headers: {
"content-encoding": "br",
"content-length": "999",
"content-type": "text/javascript",
},
}),
),
)
}),
),
),
),
Effect.map(HttpServerResponse.toWeb),
),
)
expect(response.status).toBe(200)
expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
expect(response.headers.get("content-encoding")).toBeNull()
expect(response.headers.get("content-length")).not.toBe("999")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(await response.text()).toBe("console.log('ok')")
})
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const response = await Server.Default().app.request("/session/nope")
expect(response.status).toBe(404)
})
test("requires server password for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
expect(response.status).toBe(401)
})
test("accepts auth token for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({
password: "secret",
username: "opencode",
client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
}).request(`/?auth_token=${btoa("opencode:secret")}`)
expect(response.status).toBe(200)
expect(await response.text()).toBe("<html>opencode</html>")
})
test("accepts basic auth for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(200)
})
test("allows web UI preflight without auth", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const response = await app({ password: "secret", username: "opencode" }).request("/", {
method: "OPTIONS",
headers: {
origin: "http://localhost:3000",
"access-control-request-method": "GET",
},
})
expect(response.status).toBe(204)
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
})
})
@@ -3,6 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect" import { Context, Effect, Layer, Queue } from "effect"
import { import {
FetchHttpClient,
HttpClient, HttpClient,
HttpClientRequest, HttpClientRequest,
HttpRouter, HttpRouter,
@@ -66,7 +67,7 @@ type TestHandler<E, R> = (
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R> ) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe( const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
) )
const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, test } from "bun:test" import { afterEach, describe, expect, mock } from "bun:test"
import { NodeServices } from "@effect/platform-node" import { NodeServices } from "@effect/platform-node"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
@@ -133,8 +133,6 @@ afterEach(async () => {
}) })
describe("workspace HttpApi", () => { describe("workspace HttpApi", () => {
test.todo("proxies remote workspace websocket through real Effect listener", () => {})
it.live("serves read endpoints", () => it.live("serves read endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true }) const dir = yield* tmpdirScoped({ git: true })
@@ -23,6 +23,9 @@ const svc = {
create(input?: SessionNs.CreateInput) { create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input))) return run(SessionNs.Service.use((svc) => svc.create(input)))
}, },
list(input?: SessionNs.ListInput) {
return run(SessionNs.Service.use((svc) => svc.list(input)))
},
} }
afterEach(async () => { afterEach(async () => {
@@ -55,7 +58,7 @@ describe("session.list", () => {
fn: async () => svc.create({ title: "sibling" }), fn: async () => svc.create({ title: "sibling" }),
}) })
const ids = [...svc.list()].map((s) => s.id) const ids = (await svc.list()).map((s) => s.id)
expect(ids).toContain(root.id) expect(ids).toContain(root.id)
expect(ids).toContain(parent.id) expect(ids).toContain(parent.id)
expect(ids).toContain(current.id) expect(ids).toContain(current.id)
@@ -88,7 +91,7 @@ describe("session.list", () => {
fn: async () => svc.create({ title: "sibling" }), fn: async () => svc.create({ title: "sibling" }),
}) })
const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id) const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id)
expect(ids).not.toContain(root.id) expect(ids).not.toContain(root.id)
expect(ids).not.toContain(parent.id) expect(ids).not.toContain(parent.id)
expect(ids).toContain(current.id) expect(ids).toContain(current.id)
@@ -123,9 +126,12 @@ describe("session.list", () => {
fn: async () => svc.create({ title: "sibling" }), fn: async () => svc.create({ title: "sibling" }),
}) })
const pathIDs = [ const pathIDs = (
...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }), await svc.list({
].map((s) => s.id) directory: path.join(tmp.path, "packages", "app"),
path: "packages/opencode/src",
})
).map((s) => s.id)
expect(pathIDs).not.toContain(parent.id) expect(pathIDs).not.toContain(parent.id)
expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(current.id)
expect(pathIDs).toContain(deeper.id) expect(pathIDs).toContain(deeper.id)
@@ -155,9 +161,12 @@ describe("session.list", () => {
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run())
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run())
const pathIDs = [ const pathIDs = (
...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }), await svc.list({
].map((s) => s.id) directory: path.join(tmp.path, "packages", "opencode", "src"),
path: "packages/opencode/src",
})
).map((s) => s.id)
expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(current.id)
expect(pathIDs).not.toContain(sibling.id) expect(pathIDs).not.toContain(sibling.id)
}, },
@@ -172,7 +181,7 @@ describe("session.list", () => {
const root = await svc.create({ title: "root-session" }) const root = await svc.create({ title: "root-session" })
const child = await svc.create({ title: "child-session", parentID: root.id }) const child = await svc.create({ title: "child-session", parentID: root.id })
const sessions = [...svc.list({ roots: true })] const sessions = await svc.list({ roots: true })
const ids = sessions.map((s) => s.id) const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id) expect(ids).toContain(root.id)
@@ -189,7 +198,7 @@ describe("session.list", () => {
await svc.create({ title: "new-session" }) await svc.create({ title: "new-session" })
const futureStart = Date.now() + 86400000 const futureStart = Date.now() + 86400000
const sessions = [...svc.list({ start: futureStart })] const sessions = await svc.list({ start: futureStart })
expect(sessions.length).toBe(0) expect(sessions.length).toBe(0)
}, },
}) })
@@ -203,7 +212,7 @@ describe("session.list", () => {
await svc.create({ title: "unique-search-term-abc" }) await svc.create({ title: "unique-search-term-abc" })
await svc.create({ title: "other-session-xyz" }) await svc.create({ title: "other-session-xyz" })
const sessions = [...svc.list({ search: "unique-search" })] const sessions = await svc.list({ search: "unique-search" })
const titles = sessions.map((s) => s.title) const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc") expect(titles).toContain("unique-search-term-abc")
@@ -221,7 +230,7 @@ describe("session.list", () => {
await svc.create({ title: "session-2" }) await svc.create({ title: "session-2" })
await svc.create({ title: "session-3" }) await svc.create({ title: "session-3" })
const sessions = [...svc.list({ limit: 2 })] const sessions = await svc.list({ limit: 2 })
expect(sessions.length).toBe(2) expect(sessions.length).toBe(2)
}, },
}) })
@@ -1,8 +1,8 @@
import { NodeHttpServer } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import Http from "node:http" import Http from "node:http"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect" import { Context, Effect, Layer, Queue } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -13,6 +13,8 @@ function serverUrl() {
const testServerLayer = Layer.mergeAll( const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
NodeServices.layer,
FetchHttpClient.layer,
Socket.layerWebSocketConstructorGlobal, Socket.layerWebSocketConstructorGlobal,
) )
const it = testEffect(testServerLayer) const it = testEffect(testServerLayer)
@@ -79,7 +81,13 @@ describe("HttpApi workspace proxy", () => {
const request = HttpServerRequest.fromWeb( const request = HttpServerRequest.fromWeb(
new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }),
) )
const response = yield* HttpApiProxy.http(`${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) const httpClient = yield* HttpClient.HttpClient
const response = yield* HttpApiProxy.http(
httpClient,
`${url}/session/abc?keep=yes`,
{ "x-extra": "injected" },
request,
)
expect(response.status).toBe(201) expect(response.status).toBe(201)
const client = HttpServerResponse.toClientResponse(response) const client = HttpServerResponse.toClientResponse(response)
@@ -97,7 +105,8 @@ describe("HttpApi workspace proxy", () => {
it.live("returns 500 when remote is unreachable", () => it.live("returns 500 when remote is unreachable", () =>
Effect.gen(function* () { Effect.gen(function* () {
const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything")) const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything"))
const response = yield* HttpApiProxy.http("http://127.0.0.1:1/unreachable", undefined, request) const httpClient = yield* HttpClient.HttpClient
const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request)
expect(response.status).toBe(500) expect(response.status).toBe(500)
}), }),
@@ -122,7 +131,8 @@ describe("HttpApi workspace proxy", () => {
}, },
}), }),
) )
yield* HttpApiProxy.http(`${url}/test`, { "x-injected": "extra" }, request) const httpClient = yield* HttpClient.HttpClient
yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request)
expect(forwarded["x-opencode-directory"]).toBeUndefined() expect(forwarded["x-opencode-directory"]).toBeUndefined()
expect(forwarded["x-opencode-workspace"]).toBeUndefined() expect(forwarded["x-opencode-workspace"]).toBeUndefined()
+77 -58
View File
@@ -1,16 +1,18 @@
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync" import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { EventTable } from "../../src/sync/event.sql" import { EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id" import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors" import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer))
beforeEach(() => { beforeEach(() => {
Database.close() Database.close()
@@ -22,19 +24,6 @@ afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
}) })
function withInstance(fn: () => void | Promise<void>) {
return async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
},
})
}
}
describe("SyncEvent", () => { describe("SyncEvent", () => {
function setup() { function setup() {
SyncEvent.reset() SyncEvent.reset()
@@ -59,65 +48,81 @@ describe("SyncEvent", () => {
return { Created, Sent } return { Created, Sent }
} }
function expectDefect<A, E, R>(effect: Effect.Effect<A, E, R>, pattern: RegExp) {
return Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
if (exit._tag === "Success") throw new Error("Expected effect to fail")
expect(String(exit.cause)).toMatch(pattern)
})
}
afterAll(() => { afterAll(() => {
SyncEvent.reset() SyncEvent.reset()
initProjectors() initProjectors()
}) })
describe("run", () => { describe("run", () => {
test( it.live(
"inserts event row", "inserts event row",
withInstance(() => { provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup() const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" }) yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all()) const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1") expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1") expect(rows[0].aggregate_id).toBe("evt_1")
}), }),
),
) )
test( it.live(
"increments seq per aggregate", "increments seq per aggregate",
withInstance(() => { provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup() const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" }) yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
SyncEvent.run(Created, { id: "evt_1", name: "second" }) yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all()) const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2) expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1) expect(rows[1].seq).toBe(rows[0].seq + 1)
}), }),
),
) )
test( it.live(
"uses custom aggregate field from agg()", "uses custom aggregate field from agg()",
withInstance(() => { provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Sent } = setup() const { Sent } = setup()
SyncEvent.run(Sent, { item_id: "evt_1", to: "james" }) yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all()) const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1") expect(rows[0].aggregate_id).toBe("evt_1")
}), }),
),
) )
test( it.live(
"emits events", "emits events",
withInstance(async () => { provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup() const { Created } = setup()
const events: Array<{ const events: Array<{
type: string type: string
properties: { id: string; name: string } properties: { id: string; name: string }
}> = [] }> = []
const received = new Promise<void>((resolve) => { let resolve = () => {}
Bus.subscribeAll((event) => { const received = new Promise<void>((done) => {
resolve = done
})
const dispose = Bus.subscribeAll((event) => {
events.push(event) events.push(event)
resolve() resolve()
}) })
}) try {
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" })
SyncEvent.run(Created, { id: "evt_1", name: "test" }) yield* Effect.promise(() => received)
await received
expect(events).toHaveLength(1) expect(events).toHaveLength(1)
expect(events[0]).toEqual({ expect(events[0]).toEqual({
type: "item.created", type: "item.created",
@@ -126,16 +131,21 @@ describe("SyncEvent", () => {
name: "test", name: "test",
}, },
}) })
} finally {
dispose()
}
}), }),
),
) )
}) })
describe("replay", () => { describe("replay", () => {
test( it.live(
"inserts event from external payload", "inserts event from external payload",
withInstance(() => { provideTmpdirInstance(() =>
const id = Identifier.descending("message") Effect.gen(function* () {
SyncEvent.replay({ const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1", id: "evt_1",
type: "item.created.1", type: "item.created.1",
seq: 0, seq: 0,
@@ -146,53 +156,61 @@ describe("SyncEvent", () => {
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id) expect(rows[0].aggregate_id).toBe(id)
}), }),
),
) )
test( it.live(
"throws on sequence mismatch", "throws on sequence mismatch",
withInstance(() => { provideTmpdirInstance(() =>
const id = Identifier.descending("message") Effect.gen(function* () {
SyncEvent.replay({ const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1", id: "evt_1",
type: "item.created.1", type: "item.created.1",
seq: 0, seq: 0,
aggregateID: id, aggregateID: id,
data: { id, name: "first" }, data: { id, name: "first" },
}) })
expect(() => yield* expectDefect(
SyncEvent.replay({ SyncEvent.use.replay({
id: "evt_1", id: "evt_1",
type: "item.created.1", type: "item.created.1",
seq: 5, seq: 5,
aggregateID: id, aggregateID: id,
data: { id, name: "bad" }, data: { id, name: "bad" },
}), }),
).toThrow(/Sequence mismatch/) /Sequence mismatch/,
)
}), }),
),
) )
test( it.live(
"throws on unknown event type", "throws on unknown event type",
withInstance(() => { provideTmpdirInstance(() =>
expect(() => Effect.gen(function* () {
SyncEvent.replay({ yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1", id: "evt_1",
type: "unknown.event.1", type: "unknown.event.1",
seq: 0, seq: 0,
aggregateID: "x", aggregateID: "x",
data: {}, data: {},
}), }),
).toThrow(/Unknown event type/) /Unknown event type/,
)
}), }),
),
) )
test( it.live(
"replayAll accepts later chunks after the first batch", "replayAll accepts later chunks after the first batch",
withInstance(() => { provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup() const { Created } = setup()
const id = Identifier.descending("message") const id = MessageID.ascending()
const one = SyncEvent.replayAll([ const one = yield* SyncEvent.use.replayAll([
{ {
id: "evt_1", id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version), type: SyncEvent.versionedType(Created.type, Created.version),
@@ -209,7 +227,7 @@ describe("SyncEvent", () => {
}, },
]) ])
const two = SyncEvent.replayAll([ const two = yield* SyncEvent.use.replayAll([
{ {
id: "evt_3", id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version), type: SyncEvent.versionedType(Created.type, Created.version),
@@ -232,6 +250,7 @@ describe("SyncEvent", () => {
const rows = Database.use((db) => db.select().from(EventTable).all()) const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}), }),
),
) )
}) })
}) })
-7
View File
@@ -12,13 +12,6 @@
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"], "@tui/*": ["./src/cli/cmd/tui/*"],
"@test/*": ["./test/*"] "@test/*": ["./test/*"]
},
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
} }
]
} }
} }
-329
View File
@@ -1,329 +0,0 @@
---
title: الأوضاع
description: أوضاع مختلفة لحالات استخدام مختلفة.
---
:::caution
يتم الآن ضبط الأوضاع عبر خيار `agent` في إعدادات opencode. أصبح خيار
`mode` مُهمَلًا الآن. [اعرف المزيد](/docs/agents).
:::
تتيح لك الأوضاع في opencode تخصيص السلوك والأدوات والمطالبات لحالات استخدام مختلفة.
يأتي مع وضعين مدمجين: **build** و **plan**. يمكنك تخصيصهما أو إعداد
أوضاعك الخاصة عبر إعدادات opencode.
يمكنك التبديل بين الأوضاع أثناء الجلسة أو إعدادها في ملف الإعدادات لديك.
---
## الأوضاع المدمجة
يأتي opencode مع وضعين مدمجين.
---
### Build
وضع Build هو الوضع **الافتراضي** مع تفعيل جميع الأدوات. هذا هو الوضع القياسي لأعمال التطوير عندما تحتاج إلى وصول كامل لعمليات الملفات وأوامر النظام.
---
### Plan
وضع مقيَّد مُصمَّم للتخطيط والتحليل. في وضع plan، تكون الأدوات التالية مُعطَّلة افتراضيًا:
- `write` - لا يمكن إنشاء ملفات جديدة
- `edit` - لا يمكن تعديل الملفات الموجودة، باستثناء الملفات الموجودة في `.opencode/plans/*.md` لتفصيل الخطة نفسها
- `patch` - لا يمكن تطبيق التصحيحات
- `bash` - لا يمكن تنفيذ أوامر shell
يكون هذا الوضع مفيدًا عندما تريد من الذكاء الاصطناعي تحليل الشيفرة، أو اقتراح تغييرات، أو إنشاء خطط دون إجراء أي تعديلات فعلية على قاعدة الشيفرة لديك.
---
## التبديل
يمكنك التبديل بين الأوضاع أثناء الجلسة باستخدام مفتاح _Tab_، أو اختصار `switch_mode` الذي قمت بإعداده.
انظر أيضًا: [Formatters](/docs/formatters) لمعلومات حول إعدادات تنسيق الشيفرة.
---
## الإعداد
يمكنك تخصيص الأوضاع المدمجة أو إنشاء أوضاعك الخاصة عبر الإعدادات. يمكن إعداد الأوضاع بطريقتين:
### إعدادات JSON
قم بإعداد الأوضاع في ملف الإعدادات `opencode.json` لديك:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"build": {
"model": "anthropic/claude-sonnet-4-20250514",
"prompt": "{file:./prompts/build.txt}",
"tools": {
"write": true,
"edit": true,
"bash": true
}
},
"plan": {
"model": "anthropic/claude-haiku-4-20250514",
"tools": {
"write": false,
"edit": false,
"bash": false
}
}
}
}
```
### إعدادات Markdown
يمكنك أيضًا تعريف الأوضاع باستخدام ملفات markdown. ضعها في:
- عالمي: `~/.config/opencode/modes/`
- للمشروع: `.opencode/modes/`
```markdown title="~/.config/opencode/modes/review.md"
---
model: anthropic/claude-sonnet-4-20250514
temperature: 0.1
tools:
write: false
edit: false
bash: false
---
You are in code review mode. Focus on:
- Code quality and best practices
- Potential bugs and edge cases
- Performance implications
- Security considerations
Provide constructive feedback without making direct changes.
```
يصبح اسم ملف markdown هو اسم الوضع (على سبيل المثال، `review.md` ينشئ وضعًا باسم `review`).
لنلقِ نظرة على خيارات الإعداد هذه بمزيد من التفصيل.
---
### النموذج
استخدم إعداد `model` لتجاوز النموذج الافتراضي لهذا الوضع. يفيد ذلك عند استخدام نماذج مختلفة مُحسَّنة لمهام مختلفة؛ مثل نموذج أسرع للتخطيط ونموذج أكثر قدرة للتنفيذ.
```json title="opencode.json"
{
"mode": {
"plan": {
"model": "anthropic/claude-haiku-4-20250514"
}
}
}
```
---
### درجة الحرارة
تحكَّم في العشوائية والإبداع في ردود الذكاء الاصطناعي عبر إعداد `temperature`. القيم الأقل تجعل الردود أكثر تركيزًا وحتمية، بينما تزيد القيم الأعلى الإبداع والتنوّع.
```json title="opencode.json"
{
"mode": {
"plan": {
"temperature": 0.1
},
"creative": {
"temperature": 0.8
}
}
}
```
تتراوح قيم درجة الحرارة عادةً بين 0.0 و 1.0:
- **0.0-0.2**: ردود شديدة التركيز وحتمية، مثالية لتحليل الشيفرة والتخطيط
- **0.3-0.5**: ردود متوازنة مع قدر من الإبداع، مناسبة لمهام التطوير العامة
- **0.6-1.0**: ردود أكثر إبداعًا وتنوّعًا، مفيدة للعصف الذهني والاستكشاف
```json title="opencode.json"
{
"mode": {
"analyze": {
"temperature": 0.1,
"prompt": "{file:./prompts/analysis.txt}"
},
"build": {
"temperature": 0.3
},
"brainstorm": {
"temperature": 0.7,
"prompt": "{file:./prompts/creative.txt}"
}
}
}
```
إذا لم يتم تحديد درجة الحرارة، يستخدم opencode القيم الافتراضية الخاصة بكل نموذج (عادةً 0 لمعظم النماذج و 0.55 لنماذج Qwen).
---
### الموجّه
حدِّد ملف موجّه نظام (system prompt) مخصص لهذا الوضع عبر إعداد `prompt`. ينبغي أن يحتوي ملف الموجّه على تعليمات مرتبطة بهدف هذا الوضع.
```json title="opencode.json"
{
"mode": {
"review": {
"prompt": "{file:./prompts/code-review.txt}"
}
}
}
```
هذا المسار نسبي بالنسبة لموقع ملف الإعدادات. لذلك يعمل مع
إعدادات opencode العالمية وكذلك إعدادات المشروع.
---
### الأدوات
تحكَّم في الأدوات المتاحة في هذا الوضع عبر إعداد `tools`. يمكنك تفعيل أدوات محددة أو تعطيلها بضبطها على `true` أو `false`.
```json
{
"mode": {
"readonly": {
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
إذا لم يتم تحديد أدوات، فستكون جميع الأدوات مفعّلة افتراضيًا.
---
#### الأدوات المتاحة
فيما يلي جميع الأدوات التي يمكن التحكم بها عبر إعدادات الوضع.
| الأداة | الوصف |
| ----------- | -------------------------- |
| `bash` | تنفيذ أوامر shell |
| `edit` | تعديل الملفات الموجودة |
| `write` | إنشاء ملفات جديدة |
| `read` | قراءة محتويات الملفات |
| `grep` | البحث في محتويات الملفات |
| `glob` | العثور على الملفات حسب نمط |
| `patch` | تطبيق تصحيحات على الملفات |
| `todowrite` | إدارة قوائم المهام |
| `webfetch` | جلب محتوى الويب |
---
## أوضاع مخصصة
يمكنك إنشاء أوضاعك المخصصة بإضافتها إلى الإعدادات. فيما يلي أمثلة باستخدام كلا الأسلوبين:
### باستخدام إعدادات JSON
```json title="opencode.json" {4-14}
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"docs": {
"prompt": "{file:./prompts/documentation.txt}",
"tools": {
"write": true,
"edit": true,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
### باستخدام ملفات markdown
أنشئ ملفات الأوضاع في `.opencode/modes/` لأوضاع خاصة بالمشروع أو في `~/.config/opencode/modes/` لأوضاع عالمية:
```markdown title=".opencode/modes/debug.md"
---
temperature: 0.1
tools:
bash: true
read: true
grep: true
write: false
edit: false
---
You are in debug mode. Your primary goal is to help investigate and diagnose issues.
Focus on:
- Understanding the problem through careful analysis
- Using bash commands to inspect system state
- Reading relevant files and logs
- Searching for patterns and anomalies
- Providing clear explanations of findings
Do not make any changes to files. Only investigate and report.
```
```markdown title="~/.config/opencode/modes/refactor.md"
---
model: anthropic/claude-sonnet-4-20250514
temperature: 0.2
tools:
edit: true
read: true
grep: true
glob: true
---
You are in refactoring mode. Focus on improving code quality without changing functionality.
Priorities:
- Improve code readability and maintainability
- Apply consistent naming conventions
- Reduce code duplication
- Optimize performance where appropriate
- Ensure all tests continue to pass
```
---
### حالات الاستخدام
فيما يلي بعض حالات الاستخدام الشائعة لأوضاع مختلفة.
- **Build mode**: أعمال تطوير كاملة مع تفعيل جميع الأدوات
- **Plan mode**: التحليل والتخطيط دون إجراء تغييرات
- **Review mode**: مراجعة الشيفرة مع وصول للقراءة فقط بالإضافة إلى أدوات التوثيق
- **Debug mode**: تركيز على الاستقصاء مع تفعيل أدوات `bash` و `read`
- **Docs mode**: كتابة التوثيق مع عمليات الملفات لكن دون أوامر النظام
قد تجد أيضًا أن نماذج مختلفة تكون أنسب لحالات استخدام مختلفة.

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