Compare commits

..

150 Commits

Author SHA1 Message Date
opencode 09ff3b9bb9 release: v1.1.20 2026-01-14 13:41:08 +00:00
GitHub Action 2256362ba2 chore: generate 2026-01-14 13:36:44 +00:00
Shane Bishop 077ca4454f fix(desktop): "load more" button behavior in desktop sidebar (#8430) 2026-01-14 07:36:08 -06:00
Andrew Jazbec 05cbb11709 fix(ui): layout-bottom icons (#8330) 2026-01-14 07:25:09 -06:00
Dax Raad fcc561ebb7 fix plan mode when not in git worktree 2026-01-14 08:21:26 -05:00
Filip ee6ca104e5 fix(app): file listing (#8309) 2026-01-14 07:09:36 -06:00
GitHub Action 4347a77d89 ignore: update download stats 2026-01-14 2026-01-14 12:05:15 +00:00
GitHub Action 76b10d85ee chore: generate 2026-01-14 07:37:11 +00:00
Goni Zahavy 45a770cdb1 fix(opencode): fix docker image after sst rename in tips (#8376) 2026-01-14 01:36:36 -06:00
Akshar Patel a57c8669b6 feat: show connected providers in /connect dialog (#8351) 2026-01-14 01:35:59 -06:00
zerone0x f9fcdead55 fix(session): skip duplicate system prompt for Codex OAuth sessions (#8357)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-13 23:44:39 -06:00
Github Action ff669d4414 Update aarch64-darwin hash 2026-01-14 00:59:52 +00:00
Github Action 9b2d595cfc Update Nix flake.lock and x86_64-linux hash 2026-01-14 00:54:57 +00:00
GitHub Action 3839d70a94 chore: generate 2026-01-14 00:54:06 +00:00
Frank 6fe265e7d8 Merge branch 'zen-black' into dev 2026-01-13 19:51:48 -05:00
GitHub Action 2aed4d263b chore: generate 2026-01-13 19:51:02 -05:00
Felix Sanchez e2ac588c84 fix: deduplicate file refs in sent prompts (#8303) 2026-01-13 19:51:02 -05:00
Daniel Sauer 8917dfdf5e fix(tui): track all timeouts in Footer to prevent memory leak (#8255) 2026-01-13 19:51:02 -05:00
Daniel M Brasil 86900d71f5 fix: add missing metadata() and ask() defintions to ToolContext type (#8269) 2026-01-13 19:51:02 -05:00
⌞L⌝ adcc661798 docs: add 302ai provider (#8142) 2026-01-13 19:51:01 -05:00
Eduard Voiculescu f4a28b2659 docs: Update plan mode restrictions (#8290) 2026-01-13 19:51:01 -05:00
GitHub Action a160a35d0c chore: generate 2026-01-13 19:51:01 -05:00
Leonidas 90eaf9b3fc fix(TUI): make tui work when OPENCODE_SERVER_PASSWORD is set (#8179) 2026-01-13 19:51:01 -05:00
opencode 16d516dbdb release: v1.1.19 2026-01-13 19:51:01 -05:00
Dax Raad 0026bc5815 do not allow agent to ask custom-less questions 2026-01-13 19:51:01 -05:00
Aiden Cline bcdaf7e779 tweak: prompt for explore agent better 2026-01-13 19:51:01 -05:00
GitHub Action 874e22a045 chore: generate 2026-01-13 19:51:01 -05:00
Vladimir Glafirov 905226c01e fix: Add Plugin Mocks to Provider Tests (#8276) 2026-01-13 19:51:01 -05:00
Alan 73adf7e86f fix: update User-Agent string to latest Chrome version in webfetch (#8284) 2026-01-13 19:51:01 -05:00
Dax Raad 4c37e17ac2 remove plan 2026-01-13 19:51:01 -05:00
Dax Raad cd6e07355b test: fix plan agent test path from .opencode/plan/* to .opencode/plans/* 2026-01-13 19:51:01 -05:00
GitHub Action 29703aee9a chore: generate 2026-01-13 19:51:01 -05:00
Dax 3997d3f2d7 feat: add plan mode with enter/exit tools (#8281) 2026-01-13 19:51:01 -05:00
Joe Harrison 1fccb3bda4 fix(prompt-input): handle Shift+Enter before IME check to prevent stuck state (#8275) 2026-01-13 19:51:01 -05:00
Aiden Cline 16b2bfa8ef add family to gpt 5.2 codex in codex plugin 2026-01-13 19:51:01 -05:00
Aiden Cline 4eb6b57503 tweak: external dir permission rendering in tui 2026-01-13 19:51:01 -05:00
Aiden Cline 7599396162 tweak: ensure external dir and bash tool invocations render workdir details 2026-01-13 19:51:01 -05:00
Github Action d99d1315ee Update aarch64-darwin hash 2026-01-13 19:51:01 -05:00
Github Action d831432f93 Update Nix flake.lock and x86_64-linux hash 2026-01-13 19:51:01 -05:00
Dillon Mulroy 0ddf8e6c6e fix(cli): mcp auth duplicate radio button icon (#8273) 2026-01-13 19:50:49 -05:00
Vladimir Glafirov a520c4ff98 feat: Add GitLab Duo Agentic Chat Provider Support (#7333)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-13 19:50:49 -05:00
Zeke Sikelianos a184714f67 docs: document ~/.claude/CLAUDE.md compatibility behavior (#8268) 2026-01-13 19:50:49 -05:00
Daniel Sauer 9b76337236 fix(state): delete key from recordsByKey on instance disposal (#8252) 2026-01-13 19:50:49 -05:00
Daniel Sauer 4dc72669e5 fix(mcp): close existing client before reassignment to prevent leaks (#8253) 2026-01-13 19:50:49 -05:00
Daniel Polito dfa59dd21d feat(desktop): Ask Question Tool Support (#8232) 2026-01-13 19:50:49 -05:00
GitHub Action f642a6c5b9 chore: generate 2026-01-13 19:50:49 -05:00
cmdr-chara e37104cb10 feat: add Undertale and Deltarune built-in themes (#8240) 2026-01-13 19:50:49 -05:00
Daniel Polito dc654c93d2 fix(desktop): Revert provider icon on select model dialog (#8245) 2026-01-13 19:50:49 -05:00
opencode c67b0a9ba4 release: v1.1.18 2026-01-13 19:50:49 -05:00
Leonidas 5b699a0d9b fix(github): add persist-credentials: false to workflow templates (#8202) 2026-01-13 19:50:49 -05:00
Brendan Allan bc557e828d console: reduce desktop download cache ttl to 5 minutes 2026-01-13 19:50:49 -05:00
GitHub Action fcaa041ef9 chore: generate 2026-01-13 19:50:49 -05:00
Daniel Polito 3c9d80d75f feat(desktop): Adding Provider Icons (#8215) 2026-01-13 19:50:49 -05:00
usvimal a761f66a16 fix(desktop): correct health check endpoint URL to /global/health (#8231) 2026-01-13 19:50:49 -05:00
GitHub Action 15e80fca69 chore: generate 2026-01-13 19:50:49 -05:00
Dax Raad 43680534df add fullscreen view to permission prompt 2026-01-13 19:50:48 -05:00
opencode aa522aad62 release: v1.1.17 2026-01-13 19:50:48 -05:00
Frank 82319bbd83 wip: black 2026-01-13 19:46:14 -05:00
Frank 45fa4eda15 wip: black 2026-01-13 19:15:14 -05:00
GitHub Action f242541ef3 chore: generate 2026-01-14 00:04:24 +00:00
Felix Sanchez 562f067131 fix: deduplicate file refs in sent prompts (#8303) 2026-01-13 18:03:45 -06:00
Daniel Sauer 1ff46c75fa fix(tui): track all timeouts in Footer to prevent memory leak (#8255) 2026-01-13 18:03:34 -06:00
Daniel M Brasil 73d5cacc06 fix: add missing metadata() and ask() defintions to ToolContext type (#8269) 2026-01-13 17:31:18 -06:00
⌞L⌝ b8828f2609 docs: add 302ai provider (#8142) 2026-01-13 17:00:23 -06:00
Eduard Voiculescu 2f7b2cf603 docs: Update plan mode restrictions (#8290) 2026-01-13 16:52:02 -06:00
Frank eaf18d9915 wip: black 2026-01-13 17:51:21 -05:00
GitHub Action 7aa7dd3690 chore: generate 2026-01-13 22:50:56 +00:00
Leonidas bee4b6801e fix(TUI): make tui work when OPENCODE_SERVER_PASSWORD is set (#8179) 2026-01-13 16:50:19 -06:00
opencode 3565d8e44d release: v1.1.19 2026-01-13 22:27:16 +00:00
Dax Raad 0187b6bb72 do not allow agent to ask custom-less questions 2026-01-13 17:14:12 -05:00
Aiden Cline 0eb898abcf tweak: prompt for explore agent better 2026-01-13 15:35:52 -06:00
GitHub Action 5a309c2dbf chore: generate 2026-01-13 21:24:19 +00:00
Vladimir Glafirov 452f11ff77 fix: Add Plugin Mocks to Provider Tests (#8276) 2026-01-13 15:23:41 -06:00
Alan 774c24e76e fix: update User-Agent string to latest Chrome version in webfetch (#8284) 2026-01-13 15:23:08 -06:00
Dax Raad ec4a44087b remove plan 2026-01-13 16:20:05 -05:00
Dax Raad 501347cda5 test: fix plan agent test path from .opencode/plan/* to .opencode/plans/* 2026-01-13 16:19:14 -05:00
GitHub Action 3f3816c0f2 chore: generate 2026-01-13 20:56:28 +00:00
Dax 0a3c72d678 feat: add plan mode with enter/exit tools (#8281) 2026-01-13 15:55:48 -05:00
Joe Harrison 66b7a4991e fix(prompt-input): handle Shift+Enter before IME check to prevent stuck state (#8275) 2026-01-13 14:06:38 -06:00
Aiden Cline 1550ae47c0 add family to gpt 5.2 codex in codex plugin 2026-01-13 13:57:34 -06:00
Aiden Cline 33ba064c40 tweak: external dir permission rendering in tui 2026-01-13 13:52:16 -06:00
Aiden Cline 96ae5925c3 tweak: ensure external dir and bash tool invocations render workdir details 2026-01-13 13:52:15 -06:00
Github Action 3a750b0809 Update aarch64-darwin hash 2026-01-13 19:29:19 +00:00
Github Action 1258f7aeea Update Nix flake.lock and x86_64-linux hash 2026-01-13 19:22:49 +00:00
Dillon Mulroy 797a56873d fix(cli): mcp auth duplicate radio button icon (#8273) 2026-01-13 13:22:26 -06:00
Vladimir Glafirov 05867f9318 feat: Add GitLab Duo Agentic Chat Provider Support (#7333)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-13 13:21:39 -06:00
Zeke Sikelianos 5947fe72e4 docs: document ~/.claude/CLAUDE.md compatibility behavior (#8268) 2026-01-13 12:58:09 -06:00
Github Action f3d4dd5099 Update aarch64-darwin hash 2026-01-13 18:43:58 +00:00
Daniel Sauer b68a4a8838 fix(state): delete key from recordsByKey on instance disposal (#8252) 2026-01-13 12:43:16 -06:00
Github Action b7a1d8f2f5 Update Nix flake.lock and x86_64-linux hash 2026-01-13 18:39:01 +00:00
Daniel Sauer 80e1173ef7 fix(mcp): close existing client before reassignment to prevent leaks (#8253) 2026-01-13 12:38:34 -06:00
Frank 8ae10f1c94 sync 2026-01-13 13:37:48 -05:00
Frank f24251f89e sync 2026-01-13 13:36:37 -05:00
Daniel Polito 3600bd27f4 feat(desktop): Ask Question Tool Support (#8232) 2026-01-13 12:28:08 -06:00
GitHub Action 92089bb295 chore: generate 2026-01-13 18:27:28 +00:00
cmdr-chara a70932f742 feat: add Undertale and Deltarune built-in themes (#8240) 2026-01-13 12:26:45 -06:00
Daniel Polito 217cf24c3c fix(desktop): Revert provider icon on select model dialog (#8245) 2026-01-13 12:26:21 -06:00
opencode c87939ad12 release: v1.1.18 2026-01-13 16:17:17 +00:00
Leonidas a092f567b7 fix(github): add persist-credentials: false to workflow templates (#8202) 2026-01-13 09:53:30 -06:00
Brendan Allan 883a6577d5 console: reduce desktop download cache ttl to 5 minutes 2026-01-13 23:51:16 +08:00
GitHub Action 20b52cad2a chore: generate 2026-01-13 15:42:58 +00:00
Daniel Polito 528291532b feat(desktop): Adding Provider Icons (#8215) 2026-01-13 09:41:35 -06:00
usvimal 2b77a84c4f fix(desktop): correct health check endpoint URL to /global/health (#8231) 2026-01-13 09:39:43 -06:00
GitHub Action 7d0b52dc29 chore: generate 2026-01-13 14:58:51 +00:00
Dax Raad c86c2acf4c add fullscreen view to permission prompt 2026-01-13 09:58:08 -05:00
opencode 29bf731d47 release: v1.1.17 2026-01-13 14:41:54 +00:00
Brendan Allan 3c5a256f0f desktop: macos killall opencode-cli on launch 2026-01-13 22:38:24 +08:00
GitHub Action 067338bc25 chore: generate 2026-01-13 14:17:35 +00:00
OpeOginni 736cd10847 fix(ui): track memo-based items in useFilteredList without affecting async function based lists (#8216)
Co-authored-by: neriousy <neriousy@users.noreply.github.com>
2026-01-13 08:16:57 -06:00
GitHub Action a03daa4252 chore: generate 2026-01-13 13:13:45 +00:00
Brendan Allan b01eec38d1 fix(desktop): set serverPassword 2026-01-13 21:13:06 +08:00
Brendan Allan 35cb06e0e4 fix(app): provide pty socket auth if available from desktop (#8210) 2026-01-13 07:08:19 -06:00
Adam f3b7d2f786 fix(app): file search 2026-01-13 06:55:21 -06:00
GitHub Action 1facf7d8e4 ignore: update download stats 2026-01-13 2026-01-13 12:05:22 +00:00
Brendan Allan ddd9c71cca feat(desktop): Tie desktop & CLI to the same Windows JobObject (#8153) 2026-01-13 15:32:54 +08:00
GitHub Action 520a814fc2 chore: generate 2026-01-13 07:08:25 +00:00
Aiden Cline 2072c8681a fix: remove the symlinkBinary function call that replaces the wrapper script (#8133)
Co-authored-by: Chuck Chen <459052+chuckchen@users.noreply.github.com>
2026-01-13 01:07:35 -06:00
zerone0x 21990621e2 fix(tui): prevent question tool keybindings when dialog is open (#8147)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-13 01:04:49 -06:00
GitHub Action 68a0947292 chore: generate 2026-01-13 07:01:50 +00:00
Aiden Cline f1f44644e2 fix: brew autoupgrade 2026-01-13 01:01:06 -06:00
opencode afb8a0d28a release: v1.1.16 2026-01-13 06:01:17 +00:00
Brendan Allan f05f175842 feat(desktop): spawn local server with password (#8139) 2026-01-13 13:58:00 +08:00
ShoeBoom c0b214232d fix(config): handle write errors when updating schema in opencode config (#8125) 2026-01-12 23:11:26 -06:00
Eric Guo f4f8f2d151 feat(cli): Support debug tool calling directly in CLI. (#6564) 2026-01-12 23:10:56 -06:00
Aiden Cline b4ad5c138e tweak: for zai ensure clear_thinking is false 2026-01-12 23:04:50 -06:00
Leonidas 789e111a0f fix(TUI): dont submit prompt when switching sessions (#8016) 2026-01-12 22:43:44 -05:00
Dax Raad eaa76dad0c get rid of extra file 2026-01-12 22:33:57 -05:00
Aiden Cline 66f9bdab32 core: tweak edit and write tool outputs to prevent agent from thinking edit didn't apply 2026-01-12 20:40:10 -06:00
lemon efaa9166fb fix: prevent [object Object] error display in console output (#8116)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-12 20:40:03 -06:00
Patrick Schiel eb2044989e fix: add missing args to windows tauri cli spawn (#8084) 2026-01-13 10:38:01 +08:00
M. Adel Alhashemi 5d37e58d34 fix(task): respect agent task permission for nested sub-agents (#8111) 2026-01-12 20:37:42 -06:00
Rohan Mukherjee 20088a87b0 fix: max completion tokens error for cloudflare (#7970) 2026-01-12 20:08:27 -06:00
Adam 05019dae76 fix(bedrock): use reasoningConfig for Anthropic models on Bedrock (#8092) 2026-01-12 18:42:19 -06:00
Darsh Patel bf37a88f7f fix: await auth.set to prevent race condition when setting API key (#8075) 2026-01-12 18:20:50 -06:00
Jérôme Benoit d839f70834 fix(nix): initialize hashes.json with per-system format (#8095) 2026-01-12 18:18:48 -06:00
Paolo Ricciuti 498a4ab408 fix: actually modify opencode config with mcp add (#7339) 2026-01-12 18:02:59 -06:00
GitHub Action 835e48cd28 chore: generate 2026-01-12 23:30:47 +00:00
OpeOginni 1a5a63843e feat(terminal): implement better dynamic terminal numbering (#8081) 2026-01-12 17:30:09 -06:00
Jason Kölker d954e1e3b6 fix(session): store api url in metadata (#8090) 2026-01-12 17:22:42 -06:00
opencode-agent[bot] 8f22a6b69d Add nova-2 to region prefix models (#8085)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-12 16:55:12 -06:00
Aiden Cline fd37d5b54e tweak: truncation dir perm 2026-01-12 16:46:47 -06:00
Frank e146083b73 wip: black 2026-01-12 17:10:50 -05:00
Adam d7a1c268d9 fix(app): sanitize markdown -> html 2026-01-12 15:50:24 -06:00
Aiden Cline 5c4345da4f test: fix read test 2026-01-12 15:49:31 -06:00
Aiden Cline 0be37cc2c6 tweak: make the .env reads ask user for permission instead of blocking 2026-01-12 15:32:43 -06:00
GitHub Action 64f0205f97 chore: generate 2026-01-12 21:27:36 +00:00
OpeOginni b3a1360ad9 feat(dialog-select-server): add icon button for server removal (#8053) 2026-01-12 15:26:58 -06:00
GitHub Action 08d4d6d4af chore: generate 2026-01-12 21:22:21 +00:00
Andrew Pashynnyk 9c69c1de9f fix(formatter): use biome check instead of format to include import sorting (#8057)
Co-authored-by: kynnyhsap <kynnyhsap@users.noreply.github.com>
2026-01-12 15:21:38 -06:00
Aiden Cline 735f3d17bc fix: ensure plurals are properly handled (#8070) 2026-01-12 15:21:01 -06:00
146 changed files with 8276 additions and 1017 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
- To test opencode in the `packages/opencode` directory you can run `bun dev`
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
- To test opencode in `packages/opencode`, run `bun dev`.
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- the default branch in this repo is `dev`
- The default branch in this repo is `dev`.
+2
View File
@@ -199,3 +199,5 @@
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
+9 -12
View File
@@ -1,19 +1,16 @@
## Style Guide
- Try to keep things in one function unless composable or reusable
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID using `any` type
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
- Keep things in one function unless composable or reusable
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
# Avoid let statements
we don't like let statements, especially combined with if/else statements.
prefer const
This is bad:
We don't like `let` statements, especially combined with if/else statements.
Prefer `const`.
Good:
@@ -32,7 +29,7 @@ else foo = 2
# Avoid else statements
Prefer early returns or even using `iife` to avoid else statements
Prefer early returns or using an `iife` to avoid else statements.
Good:
+64 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -84,10 +84,12 @@
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"vite": "catalog:",
"zod": "catalog:",
},
@@ -99,7 +101,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -126,7 +128,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -150,7 +152,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -174,7 +176,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -203,7 +205,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -232,7 +234,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -248,7 +250,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.15",
"version": "1.1.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,6 +278,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -351,7 +354,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -371,7 +374,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.15",
"version": "1.1.20",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -382,7 +385,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -395,7 +398,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -407,6 +410,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "catalog:",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
@@ -434,7 +438,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"zod": "catalog:",
},
@@ -445,7 +449,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -507,6 +511,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "5.0.119",
"diff": "8.0.2",
"dompurify": "3.3.1",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -584,6 +589,10 @@
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
"@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
@@ -904,6 +913,10 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-S0MVXsogrwbOboA/8L0CY5sBXg2HrrO8gdeUeHd9yLZDPsggFD0FzcSuzO5vBO6geUOpruRa8Hqrbb6WWu7Frw=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
@@ -1598,6 +1611,8 @@
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
@@ -1650,6 +1665,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
@@ -1828,6 +1845,8 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="],
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
@@ -2280,6 +2299,8 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
@@ -2312,6 +2333,10 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -2534,6 +2559,10 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
"graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
@@ -2762,6 +2791,8 @@
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
@@ -2794,6 +2825,8 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -3070,6 +3103,8 @@
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
"nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
@@ -3512,6 +3547,10 @@
"smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="],
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -3522,6 +3561,8 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -3676,6 +3717,8 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -3868,6 +3911,8 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -4018,6 +4063,8 @@
"@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -4260,6 +4307,8 @@
"editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768178648,
"narHash": "sha256-kz/F6mhESPvU1diB7tOM3nLcBfQe7GU7GQCymRlTi/s=",
"lastModified": 1768302833,
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3fbab70c6e69c87ea2b6e48aa6629da2aa6a23b0",
"rev": "61db79b0c6b838d9894923920b612048e1201926",
"type": "github"
},
"original": {
+6 -5
View File
@@ -81,12 +81,13 @@ This will walk you through installing the GitHub app, creating the workflow, and
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
persist-credentials: false
- name: Run opencode
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+3
View File
@@ -122,6 +122,7 @@ const ZEN_MODELS = [
]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
@@ -163,6 +164,7 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev
? [
@@ -176,6 +178,7 @@ new sst.cloudflare.x.SolidStart("Console", {
//VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!),
VITE_AUTH_URL: auth.url.apply((url) => url!),
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
},
transform: {
server: {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8nur5CuUCSV/SzD16hNXVoIlKsiPBXDzCnoITK0IhC4=",
"aarch64-darwin": "sha256-vD1g9dviI2nMBTTPwI87sK01hSZ+cdnmb1V72AdJYq4="
"x86_64-linux": "sha256-wENwhwRVfgoVyA9YNGcG+fAfu46JxK4xvNgiPbRt//s=",
"aarch64-darwin": "sha256-vm1DYl1erlbaqz5NHHlnZEMuFmidr/UkS84nIqLJ96Q="
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
if [ ! -f "$HASH_FILE" ]; then
cat >"$HASH_FILE" <<EOF
{
"nodeModules": "$DUMMY"
"nodeModules": {}
}
EOF
fi
@@ -111,7 +111,7 @@ fi
write_node_modules_hash "$CORRECT_HASH"
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
+1
View File
@@ -36,6 +36,7 @@
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"ai": "5.0.119",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.15",
"version": "1.1.20",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -33,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean }
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
}
}
@@ -5,6 +5,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
@@ -116,6 +117,10 @@ export function DialogSelectServer() {
select(value, true)
}
async function handleRemove(url: string) {
server.remove(url)
}
return (
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
<div class="flex flex-col gap-4 pb-4">
@@ -130,20 +135,33 @@ export function DialogSelectServer() {
}}
>
{(i) => (
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
<Show when={current() !== i && server.list.includes(i)}>
<IconButton
icon="circle-x"
variant="ghost"
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
onClick={(e) => {
e.stopPropagation()
handleRemove(i)
}}
/>
</Show>
</div>
)}
</List>
+25 -7
View File
@@ -33,6 +33,8 @@ import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
@@ -362,6 +364,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setStore("popover", null)
})
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
const agentList = createMemo(() =>
@@ -879,6 +887,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
// Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input
// and should always insert a newline regardless of composition state
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
if (event.key === "Enter" && isImeComposing(event)) {
return
}
@@ -942,11 +958,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
// Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
@@ -1560,6 +1572,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
@@ -1569,6 +1584,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ModelSelectorPopover>
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost">
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
@@ -1583,10 +1601,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
onClick={() => local.model.variant.cycle()}
>
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
{local.model.variant.current() ?? "Default"}
</Button>
</TooltipKeybind>
</Show>
+6 -3
View File
@@ -100,9 +100,12 @@ export const Terminal = (props: TerminalProps) => {
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
const socket = new WebSocket(
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
ws = socket
const t = new mod.Terminal({
+2 -1
View File
@@ -9,11 +9,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
name: "GlobalSDK",
init: () => {
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
fetch: platform.fetch,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
@@ -93,7 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
stop()
})
const platform = usePlatform()
const sdk = createOpencodeClient({
baseUrl: server.url,
fetch: platform.fetch,
+88 -3
View File
@@ -16,6 +16,7 @@ import {
type LspStatus,
type VcsInfo,
type PermissionRequest,
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -26,6 +27,7 @@ import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
type State = {
status: "loading" | "partial" | "complete"
@@ -36,6 +38,7 @@ type State = {
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
@@ -48,6 +51,9 @@ type State = {
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp: {
[name: string]: McpStatus
}
@@ -64,6 +70,7 @@ type State = {
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
error?: InitError
@@ -92,10 +99,12 @@ function createGlobalSync() {
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
@@ -110,8 +119,10 @@ function createGlobalSync() {
async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
globalSDK.client.session
.list({ directory })
const limit = store.limit
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
@@ -121,10 +132,12 @@ function createGlobalSync() {
.sort((a, b) => a.id.localeCompare(b.id))
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
if (i < limit) return true
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
// Store total session count (used for "load more" pagination)
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" }))
})
.catch((err) => {
@@ -139,6 +152,7 @@ function createGlobalSync() {
const [store, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
@@ -205,6 +219,38 @@ function createGlobalSync() {
}
})
}),
sdk.question.list().then((x) => {
const grouped: Record<string, QuestionRequest[]> = {}
for (const question of x.data ?? []) {
if (!question?.id || !question.sessionID) continue
const existing = grouped[question.sessionID]
if (existing) {
existing.push(question)
continue
}
grouped[question.sessionID] = [question]
}
batch(() => {
for (const sessionID of Object.keys(store.question)) {
if (grouped[sessionID]) continue
setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
setStore(
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
@@ -393,9 +439,48 @@ function createGlobalSync() {
)
break
}
case "question.asked": {
const sessionID = event.properties.sessionID
const questions = store.question[sessionID]
if (!questions) {
setStore("question", sessionID, [event.properties])
break
}
const result = Binary.search(questions, event.properties.id, (q) => q.id)
if (result.found) {
setStore("question", sessionID, result.index, reconcile(event.properties))
break
}
setStore(
"question",
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const questions = store.question[event.properties.sessionID]
if (!questions) break
const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
if (!result.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
+15 -1
View File
@@ -8,6 +8,7 @@ import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
title: string
titleNumber: number
rows?: number
cols?: number
buffer?: string
@@ -42,8 +43,20 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
const existingTitleNumbers = new Set(
store.all.map((pty) => {
const match = pty.titleNumber
return match
}),
)
let nextNumber = 1
while (existingTitleNumbers.has(nextNumber)) {
nextNumber++
}
sdk.client.pty
.create({ title: `Terminal ${store.all.length + 1}` })
.create({ title: `Terminal ${nextNumber}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
@@ -52,6 +65,7 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
{
id,
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
},
])
setStore("active", id)
@@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
export default function Layout(props: ParentProps) {
const params = useParams()
@@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) {
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
sdk.client.question.reply(input)
const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
@@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) {
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider>
+1 -1
View File
@@ -944,7 +944,7 @@ export default function Layout(props: ParentProps) {
.toSorted(sortSessions),
)
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const hasMoreSessions = createMemo(() => store.sessionTotal > store.session.length)
const loadMoreSessions = async () => {
setProjectStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
+4 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.15",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
},
@@ -23,10 +23,12 @@
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"vite": "catalog:",
"zod": "catalog:"
},
@@ -1,24 +0,0 @@
import { useSession } from "@solidjs/start/http"
export interface AuthSession {
account?: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: "0".repeat(32),
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,
},
})
}
+26 -1
View File
@@ -5,13 +5,38 @@ import { redirect } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { createClient } from "@openauthjs/openauth/client"
import { useAuthSession } from "./auth.session"
export const AuthClient = createClient({
clientID: "app",
issuer: import.meta.env.VITE_AUTH_URL,
})
import { useSession } from "@solidjs/start/http"
import { Resource } from "@opencode-ai/console-resource"
export interface AuthSession {
account?: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: Resource.ZEN_SESSION_SECRET.value,
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,
},
})
}
export const getActor = async (workspace?: string): Promise<Actor.Info> => {
"use server"
const evt = getRequestEvent()
@@ -1,10 +1,11 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth.session"
import { useAuthSession } from "~/context/auth"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
try {
const code = url.searchParams.get("code")
if (!code) throw new Error("No code found")
@@ -27,7 +28,7 @@ export async function GET(input: APIEvent) {
current: id,
}
})
return redirect("/auth")
return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", ""))
} catch (e: any) {
return new Response(
JSON.stringify({
@@ -2,6 +2,9 @@ import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
export async function GET(input: APIEvent) {
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
const url = new URL(input.request.url)
const cont = url.searchParams.get("continue") ?? ""
const callbackUrl = new URL(`./callback${cont}`, input.request.url)
const result = await AuthClient.authorize(callbackUrl.toString(), "code")
return Response.redirect(result.url, 302)
}
@@ -1,6 +1,6 @@
import { redirect } from "@solidjs/router"
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth.session"
import { useAuthSession } from "~/context/auth"
export async function GET(event: APIEvent) {
const auth = await useAuthSession()
@@ -1,5 +1,5 @@
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth.session"
import { useAuthSession } from "~/context/auth"
export async function GET(input: APIEvent) {
const session = await useAuthSession()
+742
View File
@@ -0,0 +1,742 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero"] {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
margin-top: 40px;
padding: 0 20px;
@media (min-width: 768px) {
margin-top: 60px;
}
h1 {
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%;
margin: 0;
@media (min-width: 768px) {
font-size: 24px;
}
}
p {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
margin: 0;
@media (min-width: 768px) {
font-size: 18px;
}
}
}
[data-slot="hero-black"] {
margin-top: 40px;
padding: 0 20px;
@media (min-width: 768px) {
margin-top: 60px;
}
svg {
width: 100%;
max-width: 540px;
height: auto;
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1));
}
}
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
text-align: center;
margin-top: -40px;
width: 100%;
@media (min-width: 768px) {
margin-top: -20px;
}
[data-slot="heading"] {
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 28.8px */
span {
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
@media (min-width: 768px) {
font-size: 18px;
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: "JetBrains Mono Nerd Font";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="pricing"] {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 540px;
padding: 0 20px;
@media (min-width: 768px) {
padding: 0;
}
}
[data-slot="pricing-card"] {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
text-decoration: none;
transition: border-color 0.15s ease;
background: transparent;
cursor: pointer;
text-align: left;
&:hover {
border-color: rgba(255, 255, 255, 0.35);
}
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
}
[data-slot="selected-plan"] {
display: flex;
flex-direction: column;
gap: 32px;
width: fit-content;
max-width: calc(100% - 40px);
margin: 0 auto;
}
[data-slot="selected-card"] {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
width: fit-content;
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
[data-slot="terms"] {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
li {
color: rgba(255, 255, 255, 0.59);
font-size: 13px;
line-height: 1.5;
padding-left: 16px;
position: relative;
white-space: nowrap;
&::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.39);
}
}
}
[data-slot="actions"] {
display: flex;
gap: 16px;
margin-top: 8px;
button,
a {
flex: 1;
display: inline-flex;
height: 48px;
padding: 0 16px;
justify-content: center;
align-items: center;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 400;
text-decoration: none;
cursor: pointer;
}
[data-slot="cancel"] {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
&:hover {
border-color: rgba(255, 255, 255, 0.35);
}
}
[data-slot="continue"] {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
font-style: italic;
a {
color: rgba(255, 255, 255, 0.39);
text-decoration: underline;
}
}
}
/* Subscribe page styles */
[data-slot="subscribe-form"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
margin-top: -18px;
width: 100%;
max-width: 540px;
padding: 0 20px;
@media (min-width: 768px) {
margin-top: 40px;
padding: 0;
}
[data-slot="form-card"] {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
[data-slot="plan-header"] {
display: flex;
flex-direction: column;
gap: 8px;
}
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-weight: 400;
margin-bottom: 8px;
}
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin: 0 8px;
}
}
[data-slot="divider"] {
height: 1px;
background: rgba(255, 255, 255, 0.17);
}
[data-slot="section-title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-weight: 400;
}
[data-slot="tax-id-section"] {
display: flex;
flex-direction: column;
gap: 8px;
[data-slot="label"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="input"] {
width: 100%;
height: 44px;
padding: 0 12px;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
color: #ffffff;
font-family: var(--font-mono);
font-size: 14px;
outline: none;
transition: border-color 0.15s ease;
&::placeholder {
color: rgba(255, 255, 255, 0.39);
}
&:focus {
border-color: rgba(255, 255, 255, 0.35);
}
}
}
[data-slot="checkout-form"] {
display: flex;
flex-direction: column;
gap: 20px;
}
[data-slot="error"] {
color: #ff6b6b;
font-size: 14px;
}
[data-slot="submit-button"] {
width: 100%;
height: 48px;
background: rgba(255, 255, 255, 0.92);
border: none;
border-radius: 4px;
color: #000;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:hover:not(:disabled) {
background: #e0e0e0;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
[data-slot="charge-notice"] {
color: #d4a500;
font-size: 14px;
text-align: center;
}
[data-slot="success"] {
display: flex;
flex-direction: column;
gap: 24px;
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-weight: 400;
margin: 0;
}
[data-slot="details"] {
display: flex;
flex-direction: column;
gap: 16px;
> div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
}
dt {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
font-weight: 400;
}
dd {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 400;
margin: 0;
text-align: right;
}
}
[data-slot="charge-notice"] {
color: #d4a500;
font-size: 14px;
text-align: left;
}
}
[data-slot="loading"] {
display: flex;
justify-content: center;
padding: 40px 0;
p {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: italic;
a {
color: rgba(255, 255, 255, 0.39);
text-decoration: underline;
}
}
[data-slot="workspace-picker"] {
[data-slot="workspace-list"] {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="workspace-item"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
&:hover,
&[data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
import { Match, Switch } from "solid-js"
export const plans = [
{ id: "20", multiplier: null },
{ id: "100", multiplier: "6x more usage than Black 20" },
{ id: "200", multiplier: "21x more usage than Black 20" },
] as const
export type PlanID = (typeof plans)[number]["id"]
export type Plan = (typeof plans)[number]
export function PlanIcon(props: { plan: string }) {
return (
<Switch>
<Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
</svg>
</Match>
<Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
</svg>
</Match>
<Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
</svg>
</Match>
</Switch>
)
}
@@ -1,221 +0,0 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero-black"] {
margin-top: 110px;
@media (min-width: 768px) {
margin-top: 150px;
}
}
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
text-align: center;
margin-top: -18px;
@media (min-width: 768px) {
margin-top: 40px;
}
[data-slot="heading"] {
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 28.8px */
span {
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
@media (min-width: 768px) {
font-size: 18px;
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: "JetBrains Mono Nerd Font";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,450 @@
import { A, createAsync, query, redirect, useParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"
import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js"
import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe"
import { PlanID, plans } from "../common"
import { getActor, useAuthSession } from "~/context/auth"
import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { createList } from "solid-list"
import { Modal } from "~/component/modal"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
const getWorkspaces = query(async () => {
"use server"
const actor = await getActor()
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe")
return withActor(async () => {
return Database.use((tx) =>
tx
.select({
id: WorkspaceTable.id,
name: WorkspaceTable.name,
slug: WorkspaceTable.slug,
billing: {
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
subscriptionID: BillingTable.subscriptionID,
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
},
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
.where(
and(
eq(UserTable.accountID, Actor.account()),
isNull(WorkspaceTable.timeDeleted),
isNull(UserTable.timeDeleted),
),
),
)
})
}, "black.subscribe.workspaces")
const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
"use server"
const { plan, workspaceID } = input
if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
if (!workspaceID) return { error: "Workspace ID is required" }
return withActor(async () => {
const session = await useAuthSession()
const account = session.data.account?.[session.data.current ?? ""]
const email = account?.email
const customer = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (customer?.subscriptionID) {
return { error: "This workspace already has a subscription" }
}
let customerID = customer?.customerID
if (!customerID) {
const customer = await Billing.stripe().customers.create({
email,
metadata: {
workspaceID,
},
})
customerID = customer.id
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
customerID,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
}
const intent = await Billing.stripe().setupIntents.create({
customer: customerID,
payment_method_types: ["card"],
metadata: {
workspaceID,
},
})
return { clientSecret: intent.client_secret ?? undefined }
}, workspaceID)
}
const bookSubscription = async (input: {
workspaceID: string
plan: PlanID
paymentMethodID: string
paymentMethodType: string
paymentMethodLast4?: string
}) => {
"use server"
return withActor(
() =>
Database.use((tx) =>
tx
.update(BillingTable)
.set({
paymentMethodID: input.paymentMethodID,
paymentMethodType: input.paymentMethodType,
paymentMethodLast4: input.paymentMethodLast4,
subscriptionPlan: input.plan,
timeSubscriptionBooked: new Date(),
})
.where(eq(BillingTable.workspaceID, input.workspaceID)),
),
input.workspaceID,
)
}
interface SuccessData {
plan: string
paymentMethodType: string
paymentMethodLast4?: string
}
function Failure(props: { message: string }) {
return (
<div data-slot="failure">
<p data-slot="message">Uh oh! {props.message}</p>
</div>
)
}
function Success(props: SuccessData) {
return (
<div data-slot="success">
<p data-slot="title">You're on the OpenCode Black waitlist</p>
<dl data-slot="details">
<div>
<dt>Subscription plan</dt>
<dd>OpenCode Black {props.plan}</dd>
</div>
<div>
<dt>Amount</dt>
<dd>${props.plan} per month</dd>
</div>
<div>
<dt>Payment method</dt>
<dd>
<Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
<span>
{props.paymentMethodType} - {props.paymentMethodLast4}
</span>
</Show>
</dd>
</div>
<div>
<dt>Date joined</dt>
<dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd>
</div>
</dl>
<p data-slot="charge-notice">Your card will be charged when your subscription is activated</p>
</div>
)
}
function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
const stripe = useStripe()
const elements = useElements()
const [error, setError] = createSignal<string | undefined>(undefined)
const [loading, setLoading] = createSignal(false)
const handleSubmit = async (e: Event) => {
e.preventDefault()
if (!stripe() || !elements()) return
setLoading(true)
setError(undefined)
const result = await elements()!.submit()
if (result.error) {
setError(result.error.message ?? "An error occurred")
setLoading(false)
return
}
const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({
elements: elements()!,
confirmParams: {
expand: ["payment_method"],
payment_method_data: {
allow_redisplay: "always",
},
},
redirect: "if_required",
})
if (confirmError) {
setError(confirmError.message ?? "An error occurred")
setLoading(false)
return
}
// TODO
console.log(setupIntent)
if (setupIntent?.status === "succeeded") {
const pm = setupIntent.payment_method as PaymentMethod
await bookSubscription({
workspaceID: props.workspaceID,
plan: props.plan,
paymentMethodID: pm.id,
paymentMethodType: pm.type,
paymentMethodLast4: pm.card?.last4,
})
props.onSuccess({
plan: props.plan,
paymentMethodType: pm.type,
paymentMethodLast4: pm.card?.last4,
})
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit} data-slot="checkout-form">
<PaymentElement />
<AddressElement options={{ mode: "billing" }} />
<Show when={error()}>
<p data-slot="error">{error()}</p>
</Show>
<button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
{loading() ? "Processing..." : `Subscribe $${props.plan}`}
</button>
<p data-slot="charge-notice">You will only be charged when your subscription is activated</p>
</form>
)
}
export default function BlackSubscribe() {
const workspaces = createAsync(() => getWorkspaces())
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
const [failure, setFailure] = createSignal<string | undefined>(undefined)
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
const params = useParams()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
// Resolve stripe promise once
createEffect(() => {
stripePromise.then((s) => {
if (s) setStripe(s)
})
})
// Auto-select if only one workspace
createEffect(() => {
const ws = workspaces()
if (ws?.length === 1 && !selectedWorkspace()) {
setSelectedWorkspace(ws[0].id)
}
})
// Fetch setup intent when workspace is selected (unless workspace already has payment method)
createEffect(async () => {
const id = selectedWorkspace()
if (!id) return
const ws = workspaces()?.find((w) => w.id === id)
if (ws?.billing?.subscriptionID) {
setFailure("This workspace already has a subscription")
return
}
if (ws?.billing?.paymentMethodID) {
if (!ws?.billing?.timeSubscriptionBooked) {
await bookSubscription({
workspaceID: id,
plan: planData.id,
paymentMethodID: ws.billing.paymentMethodID!,
paymentMethodType: ws.billing.paymentMethodType!,
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
})
}
setSuccess({
plan: planData.id,
paymentMethodType: ws.billing.paymentMethodType!,
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
})
return
}
const result = await createSetupIntent({ plan, workspaceID: id })
if (result.error) {
setFailure(result.error)
} else if ("clientSecret" in result) {
setClientSecret(result.clientSecret)
}
})
// Keyboard navigation for workspace picker
const { active, setActive, onKeyDown } = createList({
items: () => workspaces()?.map((w) => w.id) ?? [],
initialActive: null,
})
const handleSelectWorkspace = (id: string) => {
setSelectedWorkspace(id)
}
let listRef: HTMLUListElement | undefined
// Show workspace picker if multiple workspaces and none selected
const showWorkspacePicker = () => {
const ws = workspaces()
return ws && ws.length > 1 && !selectedWorkspace()
}
return (
<>
<Title>Subscribe to OpenCode Black</Title>
<section data-slot="subscribe-form">
<div data-slot="form-card">
<Switch>
<Match when={success()}>{(data) => <Success {...data()} />}</Match>
<Match when={failure()}>{(data) => <Failure message={data()} />}</Match>
<Match when={true}>
<>
<div data-slot="plan-header">
<p data-slot="title">Subscribe to OpenCode Black</p>
<p data-slot="price">
<span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
<Show when={planData.multiplier}>
<span data-slot="multiplier">{planData.multiplier}</span>
</Show>
</p>
</div>
<div data-slot="divider" />
<p data-slot="section-title">Payment method</p>
<Show
when={clientSecret() && selectedWorkspace() && stripe()}
fallback={
<div data-slot="loading">
<p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p>
</div>
}
>
<Elements
stripe={stripe()!}
options={{
clientSecret: clientSecret()!,
appearance: {
theme: "night",
variables: {
colorPrimary: "#ffffff",
colorBackground: "#1a1a1a",
colorText: "#ffffff",
colorTextSecondary: "#999999",
colorDanger: "#ff6b6b",
fontFamily: "JetBrains Mono, monospace",
borderRadius: "4px",
spacingUnit: "4px",
},
rules: {
".Input": {
backgroundColor: "#1a1a1a",
border: "1px solid rgba(255, 255, 255, 0.17)",
color: "#ffffff",
},
".Input:focus": {
borderColor: "rgba(255, 255, 255, 0.35)",
boxShadow: "none",
},
".Label": {
color: "rgba(255, 255, 255, 0.59)",
fontSize: "14px",
marginBottom: "8px",
},
},
},
}}
>
<IntentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} />
</Elements>
</Show>
</>
</Match>
</Switch>
</div>
{/* Workspace picker modal */}
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan">
<div data-slot="workspace-picker">
<ul
ref={listRef}
data-slot="workspace-list"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" && active()) {
handleSelectWorkspace(active()!)
} else {
onKeyDown(e)
}
}}
>
<For each={workspaces()}>
{(workspace) => (
<li
data-slot="workspace-item"
data-active={active() === workspace.id}
onMouseEnter={() => setActive(workspace.id)}
onClick={() => handleSelectWorkspace(workspace.id)}
>
<span data-slot="selected-icon">[*]</span>
<span>{workspace.name || workspace.slug}</span>
</li>
)}
</For>
</ul>
</div>
</Modal>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</section>
</>
)
}
@@ -24,7 +24,7 @@ export async function GET({ params: { platform } }: APIEvent) {
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 60 * 24,
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
@@ -1,6 +1,6 @@
import { action } from "@solidjs/router"
import { getRequestEvent } from "solid-js/web"
import { useAuthSession } from "~/context/auth.session"
import { useAuthSession } from "~/context/auth"
import { Dropdown } from "~/component/dropdown"
import "./user-menu.css"
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `time_subscription_booked` timestamp(3);
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `subscription_plan` enum('20','100','200');
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -358,6 +358,20 @@
"when": 1767931290031,
"tag": "0050_bumpy_mephistopheles",
"breakpoints": true
},
{
"idx": 51,
"version": "5",
"when": 1768341152722,
"tag": "0051_jazzy_green_goblin",
"breakpoints": true
},
{
"idx": 52,
"version": "5",
"when": 1768343920467,
"tag": "0052_aromatic_agent_zero",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.15",
"version": "1.1.20",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,4 +1,4 @@
import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -23,6 +23,8 @@ export const BillingTable = mysqlTable(
timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
timeSubscriptionBooked: utc("time_subscription_booked"),
},
(table) => [
...workspaceIndexes(table),
+8
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -130,6 +134,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.15",
"version": "1.1.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+8
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -130,6 +134,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.15",
"version": "1.1.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+8
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -130,6 +134,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.15",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await $`cd ../opencode && bun run build --single`
+2 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
const sidecarConfig = getCurrentSidecar()
@@ -10,6 +10,4 @@ const dir = "src-tauri/target/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await copyBinaryToSidecarFolder(
`${dir}/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`,
)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
+6 -1
View File
@@ -41,8 +41,13 @@ export function getCurrentSidecar(target = RUST_TARGET) {
export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) {
await $`mkdir -p src-tauri/sidecars`
const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}`
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
await $`cp ${source} ${dest}`
console.log(`Copied ${source} to ${dest}`)
}
export function windowsify(path: string) {
if (path.endsWith(".exe")) return path
return `${path}${process.platform === "win32" ? ".exe" : ""}`
}
+5 -3
View File
@@ -2814,7 +2814,9 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"uuid",
"webkit2gtk",
"windows",
]
[[package]]
@@ -5364,13 +5366,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.18.1"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde",
"serde_core",
"wasm-bindgen",
]
+9
View File
@@ -39,7 +39,16 @@ tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_JobObjects",
"Win32_System_Threading",
"Win32_Security"
] }
+1
View File
@@ -155,6 +155,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace())
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
@@ -0,0 +1,145 @@
//! Windows Job Object for reliable child process cleanup.
//!
//! This module provides a wrapper around Windows Job Objects with the
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
//! is closed (including when the parent process exits or crashes), Windows
//! automatically terminates all processes assigned to the job.
//!
//! This is more reliable than manual cleanup because it works even if:
//! - The parent process crashes
//! - The parent is killed via Task Manager
//! - The RunEvent::Exit handler fails to run
use std::io::{Error, Result};
#[cfg(windows)]
use std::sync::Mutex;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A Windows Job Object configured to kill all assigned processes when closed.
///
/// When this struct is dropped or when the owning process exits (even abnormally),
/// Windows will automatically terminate all processes that have been assigned to it.
pub struct JobObject(HANDLE);
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
// can be safely accessed from multiple threads.
unsafe impl Send for JobObject {}
unsafe impl Sync for JobObject {}
impl JobObject {
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
///
/// When the last handle to this job is closed (including on process exit),
/// Windows will terminate all processes assigned to the job.
pub fn new() -> Result<Self> {
unsafe {
// Create an anonymous job object
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
// Configure the job to kill all processes when the handle is closed
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const std::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.map_err(|e| Error::other(e.message()))?;
Ok(Self(job))
}
}
/// Assigns a process to this job object by its process ID.
///
/// Once assigned, the process will be terminated when this job object is dropped
/// or when the owning process exits.
///
/// # Arguments
/// * `pid` - The process ID of the process to assign
pub fn assign_pid(&self, pid: u32) -> Result<()> {
unsafe {
// Open a handle to the process with the minimum required permissions
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
.map_err(|e| Error::other(e.message()))?;
// Assign the process to the job
let result = AssignProcessToJobObject(self.0, process);
// Close our handle to the process - the job object maintains its own reference
let _ = CloseHandle(process);
result.map_err(|e| Error::other(e.message()))
}
}
}
impl Drop for JobObject {
fn drop(&mut self) {
unsafe {
// When this handle is closed and it's the last handle to the job,
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
let _ = CloseHandle(self.0);
}
}
}
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
/// On Windows, when the job object handle is closed (including on crash), all assigned
/// processes are automatically terminated by the OS.
#[cfg(windows)]
pub struct JobObjectState {
job: Mutex<Option<JobObject>>,
error: Mutex<Option<String>>,
}
#[cfg(windows)]
impl JobObjectState {
pub fn new() -> Self {
match JobObject::new() {
Ok(job) => Self {
job: Mutex::new(Some(job)),
error: Mutex::new(None),
},
Err(e) => {
eprintln!("Failed to create job object: {e}");
Self {
job: Mutex::new(None),
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
}
}
}
}
pub fn assign_pid(&self, pid: u32) {
if let Some(job) = self.job.lock().unwrap().as_ref() {
if let Err(e) = job.assign_pid(pid) {
eprintln!("Failed to assign process {pid} to job object: {e}");
*self.error.lock().unwrap() =
Some(format!("Failed to assign process to job object: {e}"));
} else {
println!("Assigned process {pid} to job object for automatic cleanup");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_object_creation() {
let job = JobObject::new();
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
}
}
+77 -22
View File
@@ -1,8 +1,13 @@
mod cli;
#[cfg(windows)]
mod job_object;
mod window_customizer;
use cli::{install_cli, sync_cli};
use futures::FutureExt;
use futures::future;
#[cfg(windows)]
use job_object::*;
use std::{
collections::VecDeque,
net::TcpListener,
@@ -13,22 +18,29 @@ use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, Webvie
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot;
use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone, serde::Serialize)]
struct ServerReadyData {
url: String,
password: Option<String>,
}
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
status: oneshot::Receiver<Result<ServerReadyData, String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
@@ -80,7 +92,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
state
.status
.clone()
@@ -137,13 +149,14 @@ fn get_sidecar_port() -> u32 {
}) as u32
}
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
println!("spawning sidecar on port {port}");
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
.env("OPENCODE_SERVER_PASSWORD", password)
.spawn()
.expect("Failed to spawn opencode");
@@ -184,8 +197,8 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
child
}
async fn check_server_health(url: &str) -> bool {
let health_url = format!("{}/health", url.trim_end_matches('/'));
async fn check_server_health(url: &str, password: Option<&str>) -> bool {
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build();
@@ -194,9 +207,13 @@ async fn check_server_health(url: &str) -> bool {
return false;
};
client
.get(&health_url)
.send()
let mut req = client.get(&health_url);
if let Some(password) = password {
req = req.basic_auth("opencode", Some(password));
}
req.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
@@ -206,6 +223,11 @@ async fn check_server_health(url: &str) -> bool {
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("killall")
.arg("opencode-cli")
.output();
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus existing window when another instance is launched
@@ -238,6 +260,9 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
#[cfg(windows)]
app.manage(JobObjectState::new());
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
@@ -267,7 +292,7 @@ pub fn run() {
window_builder.build().expect("Failed to create window");
let (tx, rx) = tokio::sync::oneshot::channel();
let (tx, rx) = oneshot::channel();
app.manage(ServerState::new(None, rx));
{
@@ -290,7 +315,14 @@ pub fn run() {
let res = match setup_server_connection(&app, custom_url).await {
Ok((child, url)) => {
#[cfg(windows)]
if let Some(child) = &child {
let job_state = app.state::<JobObjectState>();
job_state.assign_pid(child.pid());
}
app.state::<ServerState>().set_child(child);
Ok(url)
}
Err(e) => Err(e),
@@ -344,12 +376,18 @@ fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
async fn setup_server_connection(
app: &AppHandle,
custom_url: Option<String>,
) -> Result<(Option<CommandChild>, String), String> {
) -> Result<(Option<CommandChild>, ServerReadyData), String> {
if let Some(url) = custom_url {
loop {
if check_server_health(&url).await {
if check_server_health(&url, None).await {
println!("Connected to custom server: {}", url);
return Ok((None, url.clone()));
return Ok((
None,
ServerReadyData {
url: url.clone(),
password: None,
},
));
}
const RETRY: &str = "Retry";
@@ -374,24 +412,41 @@ async fn setup_server_connection(
let local_port = get_sidecar_port();
let local_url = format!("http://127.0.0.1:{local_port}");
if !check_server_health(&local_url).await {
match spawn_local_server(app, local_port).await {
Ok(child) => Ok(Some(child)),
if !check_server_health(&local_url, None).await {
let password = uuid::Uuid::new_v4().to_string();
match spawn_local_server(app, local_port, &password).await {
Ok(child) => Ok((
Some(child),
ServerReadyData {
url: local_url,
password: Some(password),
},
)),
Err(err) => Err(err),
}
} else {
Ok(None)
Ok((
None,
ServerReadyData {
url: local_url,
password: None,
},
))
}
.map(|child| (child, local_url))
}
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
let child = spawn_sidecar(app, port);
async fn spawn_local_server(
app: &AppHandle,
port: u32,
password: &str,
) -> Result<CommandChild, String> {
let child = spawn_sidecar(app, port, password);
let url = format!("http://127.0.0.1:{port}");
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
if timestamp.elapsed() > Duration::from_secs(30) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
@@ -400,7 +455,7 @@ async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild,
tokio::time::sleep(Duration::from_millis(10)).await;
if check_server_health(&url).await {
if check_server_health(&url, Some(password)).await {
println!("Server ready after {:?}", timestamp.elapsed());
break Ok(child);
}
+43 -13
View File
@@ -13,12 +13,11 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { Accessor, JSX, createResource } from "solid-js"
import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import pkg from "../package.json"
import { Show } from "solid-js"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -29,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
let update: Update | null = null
const platform: Platform = {
const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop",
version: pkg.version,
@@ -256,7 +255,25 @@ const platform: Platform = {
},
// @ts-expect-error
fetch: tauriFetch,
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
@@ -266,7 +283,7 @@ const platform: Platform = {
setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
},
}
})
createMenu()
@@ -276,26 +293,39 @@ root?.addEventListener("mousewheel", (e) => {
})
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
return (
<PlatformProvider value={platform}>
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<AppBaseProviders>
<ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<ServerGate>
{(data) => {
setServerPassword(data().password)
window.__OPENCODE__ ??= {}
window.__OPENCODE__.serverPassword = data().password ?? undefined
return <AppInterface defaultUrl={data().url} />
}}
</ServerGate>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource<ServerReadyData>(() => invoke("ensure_server_ready"))
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={serverUrl.state !== "pending" && serverUrl()}
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
@@ -303,7 +333,7 @@ function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element })
</div>
}
>
{(serverUrl) => props.children(serverUrl)}
{(data) => props.children(data)}
</Show>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.15",
"version": "1.1.20",
"private": true,
"type": "module",
"license": "MIT",
+8
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -130,6 +134,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.15"
version = "1.1.20"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.15",
"version": "1.1.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+8
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -130,6 +134,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.15",
"version": "1.1.20",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -70,6 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
+5 -2
View File
@@ -106,8 +106,11 @@ async function main() {
return
}
const { binaryPath, binaryName } = findBinary()
symlinkBinary(binaryPath, binaryName)
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
console.log(`Platform binary verified at: ${binaryPath}`)
console.log("Wrapper script will handle binary execution")
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)
+21 -7
View File
@@ -13,6 +13,8 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
export namespace Agent {
export const Info = z
@@ -50,13 +52,16 @@ export namespace Agent {
external_directory: {
"*": "ask",
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
@@ -70,6 +75,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
@@ -83,9 +89,14 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
".opencode/plans/*.md": "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, "plans/*.md"))]: "allow",
},
}),
user,
@@ -124,6 +135,7 @@ export namespace Agent {
read: "allow",
external_directory: {
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
}),
user,
@@ -213,14 +225,16 @@ export namespace Agent {
// Ensure Truncate.DIR is allowed unless explicitly configured
for (const name in result) {
const agent = result[name]
const explicit = agent.permission.some(
(r) => r.permission === "external_directory" && r.pattern === Truncate.DIR && r.action === "deny",
)
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB
})
if (explicit) continue
result[name].permission = PermissionNext.merge(
result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow" } }),
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }),
)
}
+143 -6
View File
@@ -1,6 +1,14 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2"
import { Identifier } from "../../../id/id"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission/next"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -8,11 +16,20 @@ export const AgentCommand = cmd({
command: "agent <name>",
describe: "show agent configuration details",
builder: (yargs) =>
yargs.positional("name", {
type: "string",
demandOption: true,
description: "Agent name",
}),
yargs
.positional("name", {
type: "string",
demandOption: true,
description: "Agent name",
})
.option("tool", {
type: "string",
description: "Tool id to execute",
})
.option("params", {
type: "string",
description: "Tool params as JSON or a JS object literal",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const agentName = args.name as string
@@ -23,7 +40,127 @@ export const AgentCommand = cmd({
)
process.exit(1)
}
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
const availableTools = await getAvailableTools(agent)
const resolvedTools = await resolveTools(agent, availableTools)
const toolID = args.tool as string | undefined
if (toolID) {
const tool = availableTools.find((item) => item.id === toolID)
if (!tool) {
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
process.exit(1)
}
if (resolvedTools[toolID] === false) {
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
process.exit(1)
}
const params = parseToolParams(args.params as string | undefined)
const ctx = await createToolContext(agent)
const result = await tool.execute(params, ctx)
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
return
}
const output = {
...agent,
tools: resolvedTools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
})
},
})
async function getAvailableTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
return ToolRegistry.tools(providerID, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = PermissionNext.disabled(
availableTools.map((tool) => tool.id),
agent.permission,
)
const resolved: Record<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = !disabled.has(tool.id)
}
return resolved
}
function parseToolParams(input?: string) {
if (!input) return {}
const trimmed = input.trim()
if (trimmed.length === 0) return {}
const parsed = iife(() => {
try {
return JSON.parse(trimmed)
} catch (jsonError) {
try {
return new Function(`return (${trimmed})`)()
} catch (evalError) {
throw new Error(
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
)
}
}
})
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Tool params must be an object.")
}
return parsed as Record<string, unknown>
}
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = Identifier.ascending("message")
const model = agent.model ?? (await Provider.defaultModel())
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: session.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
await Session.updateMessage(message)
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
return {
sessionID: session.id,
messageID,
callID: Identifier.ascending("part"),
agent: agent.name,
abort: new AbortController().signal,
metadata: () => {},
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new PermissionNext.DeniedError(ruleset)
}
}
},
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ const FileTreeCommand = cmd({
}),
async handler(args) {
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
console.log(files)
console.log(JSON.stringify(files, null, 2))
},
})
+6 -4
View File
@@ -394,6 +394,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run opencode
uses: anomalyco/opencode/github@latest${envStr}
@@ -624,7 +626,7 @@ export const GithubRunCommand = cmd({
}
} catch (e: any) {
exitCode = 1
console.error(e)
console.error(e instanceof Error ? e.message : String(e))
let msg = e
if (e instanceof $.ShellError) {
msg = e.stderr.toString()
@@ -915,7 +917,7 @@ export const GithubRunCommand = cmd({
// result should always be assistant just satisfying type checker
if (result.info.role === "assistant" && result.info.error) {
console.error(result.info)
console.error("Agent error:", result.info.error)
throw new Error(
`${result.info.error.name}: ${"message" in result.info.error ? result.info.error.message : ""}`,
)
@@ -944,7 +946,7 @@ export const GithubRunCommand = cmd({
})
if (summary.info.role === "assistant" && summary.info.error) {
console.error(summary.info)
console.error("Summary agent error:", summary.info.error)
throw new Error(
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
)
@@ -962,7 +964,7 @@ export const GithubRunCommand = cmd({
try {
return await core.getIDToken("opencode-github-action")
} catch (error) {
console.error("Failed to get OIDC token:", error)
console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error)
throw new Error(
"Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.",
)
+183 -112
View File
@@ -1,7 +1,6 @@
import { cmd } from "./cmd"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
@@ -13,6 +12,7 @@ import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
@@ -21,7 +21,7 @@ function getAuthStatusIcon(status: MCP.AuthStatus): string {
case "expired":
return "⚠"
case "not_authenticated":
return ""
return ""
}
}
@@ -366,133 +366,204 @@ export const McpLogoutCommand = cmd({
},
})
async function resolveConfigPath(baseDir: string, global = false) {
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
if (!global) {
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
}
for (const candidate of candidates) {
if (await Bun.file(candidate).exists()) {
return candidate
}
}
// Default to opencode.json if none exist
return candidates[0]
}
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
const file = Bun.file(configPath)
let text = "{}"
if (await file.exists()) {
text = await file.text()
}
// Use jsonc-parser to modify while preserving comments
const edits = modify(text, ["mcp", name], mcpConfig, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
const result = applyEdits(text, edits)
await Bun.write(configPath, result)
return configPath
}
export const McpAddCommand = cmd({
command: "add",
describe: "add an MCP server",
async handler() {
UI.empty()
prompts.intro("Add MCP server")
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add MCP server")
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const project = Instance.project
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(Instance.worktree),
resolveConfigPath(Global.Path.config, true),
])
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
prompts.outro("MCP server added successfully")
return
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
if (prompts.isCancel(name)) throw new UI.CancelledError()
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
if (prompts.isCancel(command)) throw new UI.CancelledError()
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
const mcpConfig: Config.Mcp = {
type: "local",
command: command.split(" "),
}
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
prompts.log.info("Add this to your opencode.json:")
prompts.log.info(`
"mcp": {
"${name}": {
"type": "remote",
"url": "${url}",
"oauth": {
"clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
}
}
}`)
} else {
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
prompts.log.info("Add this to your opencode.json:")
prompts.log.info(`
"mcp": {
"${name}": {
"type": "remote",
"url": "${url}",
"oauth": {}
}
}`)
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
} else {
const client = new Client({
name: "opencode",
version: "1.0.0",
})
const transport = new StreamableHTTPClientTransport(new URL(url))
await client.connect(transport)
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
}
}
prompts.outro("MCP server added successfully")
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: Config.Mcp
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
} else {
mcpConfig = {
type: "remote",
url,
}
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
prompts.outro("MCP server added successfully")
},
})
},
})
@@ -26,67 +26,82 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const connected = createMemo(() => new Set(sync.data.provider_next.connected))
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
map((provider) => {
const isConnected = connected().has(provider.id)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
footer: isConnected ? "Connected" : undefined,
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
},
}
}),
)
})
return options
@@ -225,7 +240,7 @@ function ApiMethod(props: ApiMethodProps) {
}
onConfirm={async (value) => {
if (!value) return
sdk.client.auth.set({
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
type: "api",
@@ -159,6 +159,26 @@ export function Autocomplete(props: {
})
props.setPrompt((draft) => {
if (part.type === "file") {
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
if (existingIndex !== -1) {
const existing = draft.parts[existingIndex]
if (
part.source?.text &&
existing &&
"source" in existing &&
existing.source &&
"text" in existing.source &&
existing.source.text
) {
existing.source.text.start = extmarkStart
existing.source.text.end = extmarkEnd
existing.source.text.value = virtualText
}
return
}
}
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
@@ -563,25 +563,27 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
sdk.client.session.prompt({
sessionID,
...selectedModel,
messageID,
agent: local.agent.current().name,
model: selectedModel,
variant,
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
})
sdk.client.session
.prompt({
sessionID,
...selectedModel,
messageID,
agent: local.agent.current().name,
model: selectedModel,
variant,
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
})
.catch(() => {})
}
history.append({
...store.prompt,
@@ -139,7 +139,7 @@ const TIPS = [
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
@@ -25,24 +25,27 @@ export function Footer() {
})
onMount(() => {
// Track all timeouts to ensure proper cleanup
const timeouts: ReturnType<typeof setTimeout>[] = []
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeout = setTimeout(() => tick(), 5000)
timeouts.push(setTimeout(() => tick(), 5000))
return
}
if (store.welcome) {
setStore("welcome", false)
timeout = setTimeout(() => tick(), 10_000)
timeouts.push(setTimeout(() => tick(), 10_000))
return
}
}
let timeout = setTimeout(() => tick(), 10_000)
timeouts.push(setTimeout(() => tick(), 10_000))
onCleanup(() => {
clearTimeout(timeout)
timeouts.forEach(clearTimeout)
})
})
@@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -195,6 +196,23 @@ export function Session() {
}
})
let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
if (part.state.status !== "completed") return
if (part.id === lastSwitch) return
if (part.tool === "plan_exit") {
local.agent.set("build")
lastSwitch = part.id
} else if (part.tool === "plan_enter") {
local.agent.set("plan")
lastSwitch = part.id
}
})
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
@@ -1525,6 +1543,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
function Bash(props: ToolProps<typeof BashTool>) {
const { theme } = useTheme()
const sync = useSync()
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
@@ -1534,11 +1553,36 @@ function Bash(props: ToolProps<typeof BashTool>) {
return [...lines().slice(0, 10), "…"].join("\n")
})
const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
const base = sync.data.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)
if (absolute === base) return undefined
const home = Global.Path.home
if (!home) return absolute
const match = absolute === home || absolute.startsWith(home + path.sep)
return match ? absolute.replace(home, "~") : absolute
})
const title = createMemo(() => {
const desc = props.input.description ?? "Shell"
const wd = workdirDisplay()
if (!wd) return `# ${desc}`
if (desc.includes(wd)) return `# ${desc}`
return `# ${desc} in ${wd}`
})
return (
<Switch>
<Match when={props.metadata.output !== undefined}>
<BlockTool
title={"# " + (props.input.description ?? "Shell")}
title={title()}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
@@ -11,16 +11,28 @@ import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
type PermissionStage = "permission" | "always" | "reject"
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
const cwd = process.cwd()
const home = Global.Path.home
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const relative = path.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
// outside cwd - use ~ or absolute
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
return absolute.replace(home, "~")
}
return input
return absolute
}
function filetype(input?: string) {
@@ -32,7 +44,9 @@ function filetype(input?: string) {
}
function EditBody(props: { request: PermissionRequest }) {
const { theme, syntax } = useTheme()
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const sync = useSync()
const dimensions = useTerminalDimensions()
@@ -54,7 +68,7 @@ function EditBody(props: { request: PermissionRequest }) {
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
<scrollbox height="100%">
<diff
diff={diff()}
view={view()}
@@ -74,7 +88,7 @@ function EditBody(props: { request: PermissionRequest }) {
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</scrollbox>
</Show>
</box>
)
@@ -172,86 +186,111 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
message: message || undefined,
})
}}
onCancel={() => setStore("stage", "permission")}
onCancel={() => {
setStore("stage", "permission")
}}
/>
</Match>
<Match when={store.stage === "permission"}>
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
return
}
if (option === "reject") {
if (session()?.parentID) {
setStore("stage", "reject")
return
{(() => {
const body = (
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
{(() => {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string"
? pattern.includes("*")
? path.dirname(pattern)
: pattern
: undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
return <TextBody icon="←" title={`Access external directory ` + dir} />
})()}
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
})
}
sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
}}
/>
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
fullscreen
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
return
}
if (option === "reject") {
if (session()?.parentID) {
setStore("stage", "reject")
return
}
sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
})
}
sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
}}
/>
)
return body
})()}
</Match>
</Switch>
)
@@ -327,14 +366,18 @@ function Prompt<const T extends Record<string, string>>(props: {
body: JSX.Element
options: T
escapeKey?: keyof T
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keybind = useKeybind()
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
expanded: false,
})
const diffKey = Keybind.parse("ctrl+f")[0]
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
@@ -360,17 +403,36 @@ function Prompt<const T extends Record<string, string>>(props: {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
evt.preventDefault()
evt.stopPropagation()
setStore("expanded", (v) => !v)
}
})
return (
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
const renderer = useRenderer()
const content = () => (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
: {
top: 0,
maxHeight: 15,
bottom: 0,
left: 0,
right: 0,
position: "relative",
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
@@ -403,6 +465,11 @@ function Prompt<const T extends Record<string, string>>(props: {
</For>
</box>
<box flexDirection="row" gap={2}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
@@ -413,4 +480,10 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
</box>
)
return (
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
{content()}
</Show>
)
}
@@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const other = createMemo(() => store.selected === options().length)
const custom = createMemo(() => question()?.custom !== false)
const other = createMemo(() => custom() && store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
@@ -121,6 +122,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const dialog = useDialog()
useKeyboard((evt) => {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return
// When editing "Other" textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
@@ -200,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
} else {
const opts = options()
const total = opts.length + 1 // options + "Other"
const total = opts.length + (custom() ? 1 : 0)
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
@@ -295,35 +299,37 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
</text>
<Show when={custom()}>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
</text>
</box>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</box>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
</box>
</Show>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
</box>
</Show>
</box>
</Show>
</box>
</box>
</Show>
@@ -158,7 +158,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (evt.name === "return") {
const option = selected()
if (option) {
// evt.preventDefault()
evt.preventDefault()
evt.stopPropagation()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
+16 -1
View File
@@ -9,6 +9,7 @@ import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -50,6 +51,8 @@ const startEventStream = (directory: string) => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const auth = getAuthorizationHeader()
if (auth) request.headers.set("Authorization", auth)
return Server.App().fetch(request)
}) as typeof globalThis.fetch
@@ -95,9 +98,14 @@ startEventStream(process.cwd())
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = getAuthorizationHeader()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers: input.headers,
headers,
body: input.body,
})
const response = await Server.App().fetch(request)
@@ -135,3 +143,10 @@ export const rpc = {
}
Rpc.listen(rpc)
function getAuthorizationHeader(): string | undefined {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${btoa(`${username}:${password}`)}`
}
+20 -25
View File
@@ -209,6 +209,19 @@ export namespace Config {
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
function rel(item: string, patterns: string[]) {
for (const pattern of patterns) {
const index = item.indexOf(pattern)
if (index === -1) continue
return item.slice(index + pattern.length)
}
}
function trim(file: string) {
const ext = path.extname(file)
return ext.length ? file.slice(0, -ext.length) : file
}
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
@@ -221,16 +234,9 @@ export namespace Config {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
const name = (() => {
const patterns = ["/.opencode/command/", "/command/"]
const pattern = patterns.find((p) => item.includes(p))
if (pattern) {
const index = item.indexOf(pattern)
return item.slice(index + pattern.length, -3)
}
return path.basename(item, ".md")
})()
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const file = rel(item, patterns) ?? path.basename(item)
const name = trim(file)
const config = {
name,
@@ -260,20 +266,9 @@ export namespace Config {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
// Extract relative path from agent folder for nested agents
let agentName = path.basename(item, ".md")
const agentFolderPath = item.includes("/.opencode/agent/")
? item.split("/.opencode/agent/")[1]
: item.includes("/agent/")
? item.split("/agent/")[1]
: agentName + ".md"
// If agent is in a subfolder, include folder path in name
if (agentFolderPath.includes("/")) {
const relativePath = agentFolderPath.replace(".md", "")
const pathParts = relativePath.split("/")
agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
}
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
const file = rel(item, patterns) ?? path.basename(item)
const agentName = trim(file)
const config = {
name: agentName,
@@ -1164,7 +1159,7 @@ export namespace Config {
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2))
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
}
const data = parsed.data
if (data.plugin) {
+1
View File
@@ -38,6 +38,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+1 -1
View File
@@ -96,7 +96,7 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "format", "--write", "$FILE"],
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
+1 -1
View File
@@ -147,7 +147,7 @@ try {
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
console.error(e)
console.error(e instanceof Error ? e.message : String(e))
}
process.exitCode = 1
} finally {
+1 -1
View File
@@ -138,7 +138,7 @@ export namespace Installation {
break
case "brew": {
const formula = await getBrewFormula()
cmd = $`brew install ${formula}`.env({
cmd = $`brew upgrade ${formula}`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
})
+14
View File
@@ -266,6 +266,13 @@ export namespace MCP {
status: s.status,
}
}
// Close existing client if present to prevent memory leaks
const existingClient = s.clients[name]
if (existingClient) {
await existingClient.close().catch((error) => {
log.error("Failed to close existing MCP client", { name, error })
})
}
s.clients[name] = result.mcpClient
s.status[name] = result.status
@@ -523,6 +530,13 @@ export namespace MCP {
const s = await state()
s.status[name] = result.status
if (result.mcpClient) {
// Close existing client if present to prevent memory leaks
const existingClient = s.clients[name]
if (existingClient) {
await existingClient.close().catch((error) => {
log.error("Failed to close existing MCP client", { name, error })
})
}
s.clients[name] = result.mcpClient
}
}
+1
View File
@@ -387,6 +387,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
headers: {},
release_date: "2025-12-18",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.2-codex"] = model
+6 -1
View File
@@ -14,7 +14,11 @@ import { NamedError } from "@opencode-ai/util/error"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"]
const BUILTIN = [
"opencode-copilot-auth@0.0.12",
"opencode-anthropic-auth@0.0.8",
"@gitlab/opencode-gitlab-auth@1.3.0",
]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
@@ -46,6 +50,7 @@ export namespace Plugin {
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push(...BUILTIN)
}
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth")) continue
+1
View File
@@ -58,6 +58,7 @@ export namespace State {
tasks.push(task)
}
entries.clear()
recordsByKey.delete(key)
await Promise.all(tasks)
disposalFinished = true
log.info("state disposal completed", { key })
+57 -2
View File
@@ -35,6 +35,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import { createGitLab } from "@gitlab/gitlab-ai-provider"
import { ProviderTransform } from "./transform"
export namespace Provider {
@@ -60,6 +61,7 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
"@gitlab/gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
@@ -237,6 +239,7 @@ export namespace Provider {
"nova-lite",
"nova-pro",
"nova-premier",
"nova-2",
"claude",
"deepseek",
].some((m) => modelID.includes(m))
@@ -389,6 +392,43 @@ export namespace Provider {
},
}
},
async gitlab(input) {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
const auth = await Auth.get(input.id)
const apiKey = await (async () => {
if (auth?.type === "oauth") return auth.access
if (auth?.type === "api") return auth.key
return Env.get("GITLAB_TOKEN")
})()
const config = await Config.get()
const providerConfig = config.provider?.["gitlab"]
return {
autoload: !!apiKey,
options: {
instanceUrl,
apiKey,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: { anthropicModel?: string }) {
const anthropicModel = options?.anthropicModel
return sdk.agenticChat(modelID, {
anthropicModel,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
})
},
}
},
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
@@ -418,11 +458,26 @@ export namespace Provider {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
// Custom fetch to strip Authorization header - AI Gateway uses cf-aig-authorization instead
// Sending Authorization header with invalid value causes auth errors
// Custom fetch to handle parameter transformation and auth
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
// Strip Authorization header - AI Gateway uses cf-aig-authorization instead
headers.delete("Authorization")
// Transform max_tokens to max_completion_tokens for newer models
if (init?.body && init.method === "POST") {
try {
const body = JSON.parse(init.body as string)
if (body.max_tokens !== undefined && !body.max_completion_tokens) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch (e) {
// If body parsing fails, continue with original request
}
}
return fetch(input, { ...init, headers })
},
},
@@ -364,6 +364,25 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) {
return {
high: {
reasoningConfig: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
reasoningConfig: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
// For Amazon Nova models, use reasoningConfig with maxReasoningEffort
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
@@ -457,6 +476,13 @@ export namespace ProviderTransform {
result["chat_template_args"] = { enable_thinking: true }
}
if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") {
result["thinking"] = {
type: "enabled",
clear_thinking: false,
}
}
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = sessionID
}
+1
View File
@@ -24,6 +24,7 @@ export namespace Question {
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({
ref: "QuestionInfo",
+4
View File
@@ -724,6 +724,8 @@ export namespace Server {
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
@@ -737,6 +739,8 @@ export namespace Server {
const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
for await (const session of Session.list()) {
if (query.directory !== undefined && session.directory !== query.directory) continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
sessions.push(session)
+13
View File
@@ -1,3 +1,5 @@
import { Slug } from "@opencode-ai/util/slug"
import pat from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
@@ -19,6 +21,8 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import path from "path"
import { Global } from "@/global"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -39,6 +43,7 @@ export namespace Session {
export const Info = z
.object({
id: Identifier.schema("session"),
slug: z.string(),
projectID: z.string(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
@@ -194,6 +199,7 @@ export namespace Session {
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
@@ -227,6 +233,13 @@ export namespace Session {
return result
}
export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(Identifier.schema("session"), async (id) => {
const read = await Storage.read<Info>(["session", Instance.project.id, id])
return read as Info
+10 -7
View File
@@ -55,13 +55,20 @@ export namespace LLM {
modelID: input.model.id,
providerID: input.model.providerID,
})
const [language, cfg] = await Promise.all([Provider.getLanguage(input.model), Config.get()])
const [language, cfg, provider, auth] = await Promise.all([
Provider.getLanguage(input.model),
Config.get(),
Provider.getProvider(input.model.providerID),
Auth.get(input.model.providerID),
])
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const system = SystemPrompt.header(input.model.providerID)
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
@@ -84,10 +91,6 @@ export namespace LLM {
system.push(header, rest.join("\n"))
}
const provider = await Provider.getProvider(input.model.providerID)
const auth = await Auth.get(input.model.providerID)
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const variant =
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small
@@ -110,7 +113,7 @@ export namespace LLM {
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
provider: Provider.getProvider(input.model.providerID),
provider,
message: input.user,
},
{
@@ -664,6 +664,7 @@ export namespace MessageV2 {
return `${msg}: ${e.responseBody}`
}).trim()
const metadata = e.url ? { url: e.url } : undefined
return new MessageV2.APIError(
{
message,
@@ -671,6 +672,7 @@ export namespace MessageV2 {
isRetryable: e.isRetryable,
responseHeaders: e.responseHeaders,
responseBody: e.responseBody,
metadata,
},
{ cause: e },
).toObject()
+129 -16
View File
@@ -510,9 +510,10 @@ export namespace SessionPrompt {
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = insertReminders({
msgs = await insertReminders({
messages: msgs,
agent,
session,
})
const processor = SessionProcessor.create({
@@ -1185,30 +1186,142 @@ export namespace SessionPrompt {
}
}
function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages
if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
// TODO (for mr dax): update to use the anthropic full fledged one (see plan-reminder-anthropic.txt)
text: PROMPT_PLAN,
synthetic: true,
})
// Original logic when experimental plan mode is disabled
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_PLAN,
synthetic: true,
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
synthetic: true,
})
}
return input.messages
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
// New plan mode logic when flag is enabled
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
// Switching from plan mode to build mode
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const plan = Session.plan(input.session)
const exists = await Bun.file(plan).exists()
if (exists) {
const part = await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text:
BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true,
})
userMessage.parts.push(part)
}
return input.messages
}
// Entering plan mode
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
const plan = Session.plan(input.session)
const exists = await Bun.file(plan).exists()
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
const part = await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
text: `<system-reminder>
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
## Plan File Info:
${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
## Plan Workflow
### Phase 1: Initial Understanding
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
1. Focus on understanding the user's request and the code associated with their request
2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
- Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
### Phase 2: Design
Goal: Design an implementation approach.
Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
You can launch up to 1 agent(s) in parallel.
**Guidelines:**
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
Examples of when to use multiple agents:
- The task touches multiple parts of the codebase
- It's a large refactor or architectural change
- There are many edge cases to consider
- You'd benefit from exploring different approaches
Example perspectives by task type:
- New feature: simplicity vs performance vs maintainability
- Bug fix: root cause vs workaround vs prevention
- Refactoring: minimal change vs clean architecture
In the agent prompt:
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
- Describe requirements and constraints
- Request a detailed implementation plan
### Phase 3: Review
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
1. Read the critical files identified by agents to deepen your understanding
2. Ensure that the plans align with the user's original request
3. Use question tool to clarify any remaining questions with the user
### Phase 4: Final Plan
Goal: Write your final plan to the plan file (the only file you can edit).
- Include only your recommended approach, not all alternatives
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
- Include the paths of critical files to be modified
- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
### Phase 5: Call plan_exit tool
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
</system-reminder>`,
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
}
return input.messages
}
+2 -2
View File
@@ -119,7 +119,7 @@ export const EditTool = Tool.define("edit", {
},
})
let output = ""
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
@@ -129,7 +129,7 @@ export const EditTool = Tool.define("edit", {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return {
+14
View File
@@ -0,0 +1,14 @@
Use this tool to suggest switching to plan agent when the user's request would benefit from planning before implementation.
If they explicitly mention wanting to create a plan ALWAYS call this tool first.
This tool will ask the user if they want to switch to plan agent.
Call this tool when:
- The user's request is complex and would benefit from planning first
- You want to research and design before making changes
- The task involves multiple files or significant architectural decisions
Do NOT call this tool:
- For simple, straightforward tasks
- When the user explicitly wants immediate implementation
+13
View File
@@ -0,0 +1,13 @@
Use this tool when you have completed the planning phase and are ready to exit plan agent.
This tool will ask the user if they want to switch to build agent to start implementing the plan.
Call this tool:
- After you have written a complete plan to the plan file
- After you have clarified any questions with the user
- When you are confident the plan is ready for implementation
Do NOT call this tool:
- Before you have created or finalized the plan
- If you still have unanswered questions about the implementation
- If the user has indicated they want to continue planning
+130
View File
@@ -0,0 +1,130 @@
import z from "zod"
import path from "path"
import { Tool } from "./tool"
import { Question } from "../question"
import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Provider } from "../provider/provider"
import { Instance } from "../project/instance"
import EXIT_DESCRIPTION from "./plan-exit.txt"
import ENTER_DESCRIPTION from "./plan-enter.txt"
async function getLastModel(sessionID: string) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
return Provider.defaultModel()
}
export const PlanExitTool = Tool.define("plan_exit", {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "build",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
},
})
export const PlanEnterTool = Tool.define("plan_enter", {
description: ENTER_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
header: "Plan Mode",
custom: false,
options: [
{ label: "Yes", description: "Switch to plan agent for research and planning" },
{ label: "No", description: "Stay with build agent to continue making changes" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "plan",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to plan agent",
output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
metadata: {},
}
},
})

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