Compare commits

..
Author SHA1 Message Date
Kit Langton c3eb2418ca fix(sync): preserve shared state and caller semantics 2026-04-11 00:10:41 -04:00
Kit Langton 397adb4190 fix(sync): restore instance context for deferred publish 2026-04-11 00:10:41 -04:00
Kit Langton dbc136d3b3 Merge branch 'dev' into kit/effect-sync-event 2026-04-10 23:54:56 -04:00
opencode-agent[bot] 2e340d976f chore: generate 2026-04-11 03:53:48 +00:00
Kit Langton fe4dfb9f6f refactor(git): remove runtime facade wrappers (#21982) 2026-04-10 23:52:48 -04:00
Kit Langton 5e3dc80999 refactor: collapse command facade (#21981) 2026-04-10 23:52:12 -04:00
Kit Langton e2c41dce99 Merge branch 'dev' into kit/effect-sync-event 2026-04-10 23:51:34 -04:00
Kit Langton d84cc33742 refactor(plugin): return Effect from ToolContext.ask (#21986) 2026-04-10 23:50:50 -04:00
Kit Langton f8580ab9f7 Merge branch 'dev' into kit/effect-sync-event 2026-04-10 23:42:10 -04:00
opencode-agent[bot] c92c462148 chore: update nix node_modules hashes 2026-04-11 03:39:49 +00:00
Kit Langton 9ca06e0336 docs(effect): mark SessionTodo migrated (#21987) 2026-04-10 23:35:50 -04:00
Kit Langton af7ada9a7a refactor(sync): migrate SyncEvent to service layer 2026-04-10 23:34:38 -04:00
opencode-agent[bot] 3b523b32f5 chore: generate 2026-04-11 03:28:30 +00:00
Kit Langton ba3600a515 refactor(session): remove dead updatePartDelta facade (#21985) 2026-04-10 23:27:30 -04:00
Kit Langton 03ce2e5288 refactor(installation): drop facade runtime wrappers (#21984) 2026-04-10 23:26:16 -04:00
Kit Langton 87e23abb10 refactor: remove ProviderAuth facade (#21983) 2026-04-10 23:25:43 -04:00
opencode-agent[bot] 2868000c20 chore: generate 2026-04-11 03:19:50 +00:00
Kit Langton f38f415bf0 refactor: collapse Format facade (#21980) 2026-04-10 23:18:54 -04:00
Kit Langton 4341ab838e refactor(tool): use Session.Service directly in TaskTool (#21975) 2026-04-10 23:18:30 -04:00
Kit Langton cd004cf0b2 refactor(session): eliminate Effect.promise roundtrips for sync MessageV2.stream (#21973) 2026-04-10 23:18:13 -04:00
opencode-agent[bot] 19ae8c88b0 chore: generate 2026-04-11 03:13:03 +00:00
Kit Langton 3dd09147c2 refactor(tool): Tool.Context.metadata returns Effect (#21972) 2026-04-10 23:12:04 -04:00
Kit Langton 9581bf0670 refactor(effect): upgrade opencode to beta.46 context APIs (#21977) 2026-04-10 23:06:28 -04:00
Kit Langton af8aff3788 refactor: make TaskPromptOps effectful (#21971) 2026-04-10 22:57:47 -04:00
opencode-agent[bot] 2a8a59ded9 chore: generate 2026-04-11 02:56:03 +00:00
Kit Langton 5917ac2162 fix: provide EffectLogger.layer to bare Effect.runPromise/runFork calls (#21974) 2026-04-10 22:55:08 -04:00
Brendan Allan b6af4d0dc6 refactor(config): pass instance context to containsPath (#21882) 2026-04-11 10:43:40 +08:00
opencode-agent[bot] 577139c626 chore: generate 2026-04-11 02:36:59 +00:00
Kit Langton c5fb6281f0 refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961) 2026-04-10 22:36:02 -04:00
Kit Langton f99812443c refactor: destroy SessionStatus facade (#21968) 2026-04-10 22:16:53 -04:00
opencode-agent[bot] b898c6d0ea chore: generate 2026-04-11 02:04:02 +00:00
Kit Langton 9e7045eaec refactor: destroy ShareNext facade (#21965) 2026-04-10 22:03:06 -04:00
Kit Langton a17ac02061 refactor: extract LSP diagnostic report formatter (#21964) 2026-04-10 22:00:56 -04:00
opencode-agent[bot] 57f9397677 chore: generate 2026-04-11 01:48:25 +00:00
Kit Langton a4c686025c refactor: destroy Todo facade (#21962) 2026-04-10 21:47:28 -04:00
Kit Langton face879100 fix: disable default Effect console logger (#21963) 2026-04-10 21:27:24 -04:00
opencode-agent[bot] 605559b165 chore: generate 2026-04-11 01:22:01 +00:00
Kit Langton 5cd4c6eb22 refactor: destroy Storage facades (#21956) 2026-04-10 21:21:02 -04:00
Kit Langton 40358d60a0 refactor: add Effect logger for motel observability (#21954) 2026-04-10 21:10:58 -04:00
Aiden Cline 96c1c0363d chore: rm unnecessary test (now we use effect) and the test is flaky (#21959) 2026-04-10 19:59:33 -05:00
Aiden Cline 33819932ec tweak: rm processor .trim calls (#21958) 2026-04-10 19:47:08 -05:00
Kit Langton 5d6fe01465 convert skill tool to Tool.defineEffect (#21936) 2026-04-10 19:49:53 -04:00
Kit Langton cf27a73397 feat: add AppRuntime for unified service composition (#21953) 2026-04-10 19:46:52 -04:00
opencode-agent[bot] f2c492a8e6 chore: generate 2026-04-10 23:43:20 +00:00
Kit Langton 0556774097 refactor(tool): convert apply_patch to Tool.defineEffect (#21938) 2026-04-10 19:42:14 -04:00
Kit Langton d9d5a0615e refactor: break SessionPrompt/TaskTool cycle via ctx injection (#21948) 2026-04-10 19:36:13 -04:00
Kit Langton d72ddd71fa refactor(tool): convert grep tool to Tool.defineEffect (#21937) 2026-04-10 19:20:00 -04:00
opencode-agent[bot] fb26308bc7 chore: generate 2026-04-10 21:12:22 +00:00
Kit Langton b41fa8e318 refactor: convert edit tool to Tool.defineEffect (#21904) 2026-04-10 17:10:28 -04:00
opencode-agent[bot] 57b2e64345 chore: generate 2026-04-10 21:01:33 +00:00
Frank 346b3e1b8d zen: nemotron doc 2026-04-10 16:58:38 -04:00
Kit Langton b139bc2ef3 refactor(tool): convert write tool to Tool.defineEffect (#21901) 2026-04-10 16:57:12 -04:00
opencode-agent[bot] 378b8ca241 chore: generate 2026-04-10 19:40:10 +00:00
Kit Langton f63bdc8e08 convert list tool to Tool.defineEffect (#21899) 2026-04-10 15:38:52 -04:00
Aiden Cline ce26120205 tweak: make it so disabling uv or ruff fmters disables both (#21921) 2026-04-10 13:30:30 -05:00
opencode-agent[bot] d2d5d84d1e chore: generate 2026-04-10 17:57:46 +00:00
Kit Langton 847f1d99c9 convert glob tool to Tool.defineEffect (#21897) 2026-04-10 13:56:42 -04:00
opencode-agent[bot] 59d08683ea chore: generate 2026-04-10 17:27:31 +00:00
Kit Langton f7514d9eca refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895) 2026-04-10 13:26:31 -04:00
James Long 180ded6a27 rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896) 2026-04-10 13:03:20 -04:00
Kit Langton bf601628db refactor(tool): convert codesearch tool internals to Effect (#21811) 2026-04-10 11:49:20 -04:00
opencode-agent[bot] 00e39d2114 chore: generate 2026-04-10 15:31:39 +00:00
Kit Langton 46b74e0873 refactor(tool): convert websearch tool internals to Effect (#21810) 2026-04-10 11:30:38 -04:00
opencode-agent[bot] aedc4e964f chore: generate 2026-04-10 14:51:27 +00:00
Kit Langton e83404367c refactor(tool): convert webfetch tool internals to Effect (#21809) 2026-04-10 10:50:13 -04:00
James Long 42206da1f8 refactor(tui): switch to global events and start passing workspace param (#21719) 2026-04-10 10:47:27 -04:00
Kit Langton 44f38193c0 refactor(tool): convert plan tool internals to Effect (#21807) 2026-04-10 10:38:46 -04:00
opencode-agent[bot] 9a6b455bfe chore: generate 2026-04-10 14:08:27 +00:00
Kit Langton 8063e0b5c6 refactor(tool): convert lsp tool internals to Effect (#21806) 2026-04-10 10:07:19 -04:00
Kit Langton 157c5d77f8 refactor(tool): convert question tool internals to Effect (#21808) 2026-04-10 09:42:06 -04:00
Aiden Cline ce19c051be fix: ts lsp (#21827) 2026-04-10 00:15:45 -05:00
Kit Langton 91786d2fc1 refactor(effect): use Git service in file and storage (#21803) 2026-04-09 22:49:36 -04:00
Kit Langton eca11ca71a refactor(effect): use SessionRevert service in prompt (#21796) 2026-04-09 22:28:11 -04:00
Kit Langton 17bd16667c refactor(effect): move tool descriptions into registry (#21795) 2026-04-09 22:20:27 -04:00
Kit Langton 16c60c9ee7 refactor(session): extract sharing orchestration (#21759) 2026-04-09 21:47:48 -04:00
Dax Raad 0970b102e1 Merge remote-tracking branch 'origin/dev' into dev 2026-04-09 21:35:09 -04:00
Dax Raad 04074d3f4a core: enable prod channel to use shared production database
Ensures users on the prod channel have their data persisted to the same
database as latest and beta channels, preventing data fragmentation
across different release channels.
2026-04-09 21:34:52 -04:00
Luke Parker b16ee08fd5 ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 01:00:21 +00:00
Luke Parker 98874a09f7 fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 01:00:21 +00:00
opencode 877be7e8e0 release: v1.4.3 2026-04-10 01:00:12 +00:00
Dax Raad eac50f9151 ci: prevent beta branch builds from triggering production release steps
Skip Windows and Linux code signing, along with artifact downloads for
the beta branch to ensure beta builds don't go through production
release processes.
2026-04-09 17:06:53 -04:00
Dax Raad 1a902b291c ci: skip winget publish on beta and ensure finalize always runs
Beta releases no longer trigger unnecessary Winget submissions, and release
finalization now completes even when some build artifacts are missing.
2026-04-09 16:34:05 -04:00
opencode-agent[bot] bbe4a04f9f chore: generate 2026-04-09 20:29:48 +00:00
Kit Langton b2f621b897 refactor(session): inline init route orchestration (#21754) 2026-04-09 16:28:42 -04:00
Aiden ClineandOpeOginni 7202b3a325 fix: ensure that openai oauth works for agent create cmd, use temporary hack (#21749)
Co-authored-by: OpeOginni <brightoginni123@gmail.com>
2026-04-09 15:25:59 -05:00
opencode-agent[bot] 35b44df94a chore: generate 2026-04-09 20:05:05 +00:00
Kit Langton 10441efad1 refactor(effect): extract session run state service (#21744) 2026-04-09 16:03:40 -04:00
Kit Langton 3199383eef fix: finalize interrupted bash via tool result path (#21724) 2026-04-09 15:20:28 -04:00
Kit Langton 9f54115c5d refactor: remove unused runtime facade exports (#21731) 2026-04-09 14:52:06 -04:00
Kit Langton 2ecc6ae65f fix(effect): suspend agent default layer construction (#21732) 2026-04-09 14:32:41 -04:00
Simon Klee 02b32e1ba7 Revert "opencode: lazy-load top-level CLI commands" (#21726) 2026-04-09 20:03:48 +02:00
Kit Langton 34b9792654 delete unused withALS method (#21723) 2026-04-09 13:52:58 -04:00
Simon Klee 537160dbc0 opencode: lazy-load top-level CLI commands
The CLI imports every top-level command before argument parsing has
decided which handler will run. This makes simple invocations pay for
the full command graph up front and slows down the default startup path.

Parse the root argv first and load only the command module that matches
the selected top-level command. Keep falling back to the default TUI
path for non-command positionals, and preserve root help, version and
completion handling
2026-04-09 19:42:25 +02:00
Aiden Cline b0600664ab feat: add support for fast modes for claude and gpt models (that support it) (#21706) 2026-04-09 12:06:26 -05:00
Kit Langton 581a7692ff fix(tui): restore hidden session scrollbar default (#20947) 2026-04-09 13:01:08 -04:00
opencode-agent[bot] f73e4d5d31 chore: generate 2026-04-09 16:47:02 +00:00
Aleksandr LossenkoandAiden Cline a7743e6467 feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-09 11:45:52 -05:00
opencode 5d3dba666c release: v1.4.2 2026-04-09 16:24:43 +00:00
Dax bd53b651a3 refactor: fix tool call state handling and clean up imports (#21709) 2026-04-09 11:56:19 -04:00
Kit Langton 46da801f30 refactor(effect): drop shell abort signals from runner (#21599) 2026-04-09 10:54:26 -04:00
Kit Langton 58a99916bb fix: preserve text part timing in session processor (#21691) 2026-04-09 10:05:14 -04:00
Kit Langton c29392d085 fix: preserve interrupted bash output in tool results (#21598) 2026-04-09 10:03:26 -04:00
Brendan Allan 46f243fea7 app: remove min loading duration (#21655) 2026-04-09 16:29:46 +08:00
opencode 847fc9d268 release: v1.4.1 2026-04-09 07:12:17 +00:00
Aiden ClineandFrank 489f57974d feat: add opencode go upsell modal when limits are hit (#21583)
Co-authored-by: Frank <frank@anoma.ly>
2026-04-09 02:16:29 -04:00
opencode-agent[bot] 3fc3974cbc chore: update nix node_modules hashes 2026-04-09 06:03:26 +00:00
opencode-agent[bot] ca57248246 chore: generate 2026-04-09 05:19:52 +00:00
Brendan AllanandLukeParkerDev ee23043d64 Remove CLI from electron app (#17803)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-09 13:18:46 +08:00
Cho HyeonJong 9c1c061b84 fix(lsp): remove CMakeLists.txt and Makefile from clangd root markers (#21466) 2026-04-08 23:10:06 -05:00
opencode-agent[bot] d82b163e56 chore: update nix node_modules hashes 2026-04-09 02:27:34 +00:00
Vladimir GlafirovandAiden Cline cd8e8a9928 feat(llm): integrate GitLab DWS tool approval with permission system (#19955)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-08 20:39:33 -05:00
Kit Langton 8bdcc22541 refactor(effect): inline session processor interrupt cleanup (#21593) 2026-04-08 21:19:01 -04:00
Kit Langton 2bdd279467 fix: propagate abort signal to inline read tool (#21584) 2026-04-08 21:07:55 -04:00
OpeOginni 51535d8ef3 fix(app): skip url password setting for same-origin server and web app (#19923) 2026-04-09 07:13:10 +08:00
Kit Langton 38f8714c09 refactor(effect): build task tool from agent services (#21017) 2026-04-08 19:02:19 -04:00
Aiden Cline 4961d72c0f tweak: separate ModelsDev.Model and Config model schemas (#21561) 2026-04-08 15:55:14 -05:00
Aiden Cline 00cb8839ae fix: dont show invalid variants for BP (#21555) 2026-04-08 14:52:34 -05:00
Adam 689b1a4b3a fix(app): diff list normalization 2026-04-08 14:02:23 -05:00
Adam d98be39344 fix(app): patch tool diff rendering 2026-04-08 13:49:16 -05:00
273 changed files with 71515 additions and 7424 deletions
+6 -3
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,6 +213,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -389,6 +390,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -421,7 +423,6 @@ jobs:
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
platform_flag: --linux platform_flag: --linux
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -547,6 +548,7 @@ jobs:
- sign-cli-windows - sign-cli-windows
- build-tauri - build-tauri
- build-electron - build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -589,12 +591,13 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release if: needs.version.outputs.release && github.ref_name != 'beta'
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
+13
View File
@@ -17,6 +17,9 @@ permissions:
contents: read contents: read
checks: write checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (${{ matrix.settings.name }})
@@ -38,6 +41,11 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -102,6 +110,11 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
+63 -36
View File
@@ -27,7 +27,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -223,14 +223,8 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
@@ -238,24 +232,41 @@
"electron-updater": "^6", "electron-updater": "^6",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15", "marked": "^15",
"solid-js": "catalog:",
"tree-kill": "^1.2.2",
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "40.4.1", "electron": "40.4.1",
"electron-builder": "^26", "electron-builder": "^26",
"electron-vite": "^5", "electron-vite": "^5",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "catalog:", "vite": "catalog:",
"zod-openapi": "5.4.6",
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
}, },
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -284,7 +295,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -300,7 +311,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.4.0", "version": "1.4.3",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -336,7 +347,7 @@
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@lydell/node-pty": "1.2.0-beta.10", "@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
@@ -367,7 +378,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.0.0", "gitlab-ai-provider": "6.4.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -402,7 +413,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.79.0", "@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -436,9 +447,10 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@@ -470,7 +482,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -485,7 +497,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -520,7 +532,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -569,7 +581,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -580,7 +592,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -630,9 +642,10 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.43", "@effect/platform-node": "4.0.0-beta.46",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
@@ -656,7 +669,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43", "effect": "4.0.0-beta.46",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -1013,11 +1026,11 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.46", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.46", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2313,6 +2326,8 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -2875,7 +2890,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], "effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3165,7 +3180,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.0.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="], "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4577,6 +4592,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@@ -4655,8 +4672,6 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
@@ -4811,6 +4826,8 @@
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -4967,6 +4984,8 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -5491,6 +5510,10 @@
"@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="], "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -6417,6 +6440,10 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=", "x86_64-linux": "sha256-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=",
"aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=", "aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=",
"aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=", "aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=",
"x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w=" "x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4="
} }
} }
+4 -3
View File
@@ -26,7 +26,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.43", "@effect/platform-node": "4.0.0-beta.46",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@@ -47,7 +47,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43", "effect": "4.0.0-beta.46",
"ai": "6.0.149", "ai": "6.0.149",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
@@ -71,7 +71,8 @@
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10" "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
} }
}, },
"devDependencies": { "devDependencies": {
+7 -3
View File
@@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
} }
function done(proc: ReturnType<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) { async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return if (done(proc)) return
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())), new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)), new Promise<void>((resolve) => setTimeout(resolve, timeout)),
@@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
return { return {
url, url,
async stop() { async stop() {
if (proc.exitCode === null) { if (!done(proc)) {
proc.kill("SIGTERM") proc.kill("SIGTERM")
await waitExit(proc) await waitExit(proc)
} }
if (proc.exitCode === null) { if (!done(proc)) {
proc.kill("SIGKILL") proc.kill("SIGKILL")
await waitExit(proc) await waitExit(proc)
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.0", "version": "1.4.3",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
-1
View File
@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
if (checkMode() === "background" || type === "http") return false if (checkMode() === "background" || type === "http") return false
} }
}).pipe( }).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise, Effect.runPromise,
+7 -2
View File
@@ -174,6 +174,7 @@ export const Terminal = (props: TerminalProps) => {
const auth = server.current?.http const auth = server.current?.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
const password = auth?.password ?? "" const password = auth?.password ?? ""
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@@ -519,8 +520,12 @@ export const Terminal = (props: TerminalProps) => {
next.searchParams.set("directory", directory) next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek)) next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:" next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
next.username = username if (!sameOrigin && password) {
next.password = password next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
const socket = new WebSocket(next) const socket = new WebSocket(next)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
@@ -14,6 +14,7 @@ import type {
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -162,7 +163,7 @@ export function applyDirectoryEvent(input: {
} }
case "session.diff": { case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" })) input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break break
} }
case "todo.updated": { case "todo.updated": {
@@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "message.updated": { case "message.updated": {
const info = (event.properties as { info: Message }).info const info = clean((event.properties as { info: Message }).info)
const messages = input.store.message[info.sessionID] const messages = input.store.message[info.sessionID]
if (!messages) { if (!messages) {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
+3 -2
View File
@@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
) )
const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id)) const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return { return {
@@ -509,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return runInflight(inflightDiff, key, () => return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => { retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}), }),
) )
}, },
+7 -10
View File
@@ -58,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same" import { same } from "@/utils/same"
@@ -430,7 +431,7 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID) const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0) const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project) const canReview = createMemo(() => !!sync.project)
@@ -611,7 +612,7 @@ export default function Page() {
.diff({ mode }) .diff({ mode })
.then((result) => { .then((result) => {
if (vcsRun.get(mode) !== run) return if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, result.data ?? []) setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true) setVcs("ready", mode, true)
}) })
.catch((error) => { .catch((error) => {
@@ -649,7 +650,7 @@ export default function Page() {
return open return open
}, desktopReviewOpen()) }, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo<ChangeMode[]>(() => { const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = [] const list: ChangeMode[] = []
@@ -669,15 +670,11 @@ export default function Page() {
if (store.changes === "git" || store.changes === "branch") return store.changes if (store.changes === "git" || store.changes === "branch") return store.changes
}) })
const reviewDiffs = createMemo(() => { const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return vcs.diff.branch if (store.changes === "branch") return list(vcs.diff.branch)
return turnDiffs() return turnDiffs()
}) })
const reviewCount = createMemo(() => { const reviewCount = createMemo(() => reviewDiffs().length)
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0) const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => { const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git if (store.changes === "git") return vcs.ready.git
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
const item = {
file: "src/app.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
expect(diffs([item])).toEqual([item])
})
test("wraps a single diff object", () => {
expect(diffs(item)).toEqual([item])
})
test("reads keyed diff objects", () => {
expect(diffs({ a: item })).toEqual([item])
})
test("drops invalid entries", () => {
expect(
diffs([
item,
{ file: "src/bad.ts", additions: 1, deletions: 1 },
{ patch: item.patch, additions: 1, deletions: 1 },
]),
).toEqual([item])
})
})
describe("message", () => {
test("normalizes user summaries with object diffs", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: {
title: "Edit",
diffs: { a: item },
},
} as unknown as Message
expect(message(input)).toMatchObject({
summary: {
title: "Edit",
diffs: [item],
},
})
})
test("drops invalid user summaries", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: true,
} as unknown as Message
expect(message(input)).toMatchObject({ summary: undefined })
})
})
+49
View File
@@ -0,0 +1,49 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
if (!("additions" in value) || typeof value.additions !== "number") return false
if (!("deletions" in value) || typeof value.deletions !== "number") return false
if (!("status" in value) || value.status === undefined) return true
return value.status === "added" || value.status === "deleted" || value.status === "modified"
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function diffs(value: unknown): Diff[] {
if (Array.isArray(value) && value.every(diff)) return value
if (Array.isArray(value)) return value.filter(diff)
if (diff(value)) return [value]
if (!object(value)) return []
return Object.values(value).filter(diff)
}
export function message(value: Message): Message {
if (value.role !== "user") return value
const raw = value.summary as unknown
if (raw === undefined) return value
if (!object(raw)) return { ...value, summary: undefined }
const title = typeof raw.title === "string" ? raw.title : undefined
const body = typeof raw.body === "string" ? raw.body : undefined
const next = diffs(raw.diffs)
if (title === raw.title && body === raw.body && next === raw.diffs) return value
return {
...value,
summary: {
...(title === undefined ? {} : { title }),
...(body === undefined ? {} : { body }),
diffs: next,
},
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.0", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.0", "version": "1.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.0", "version": "1.4.3",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.0", "version": "1.4.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -34,11 +34,6 @@ const getBase = (): Configuration => ({
}, },
files: ["out/**/*", "resources/**/*"], files: ["out/**/*", "resources/**/*"],
extraResources: [ extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
{ {
from: "native/", from: "native/",
to: "native/", to: "native/",
@@ -1,5 +1,6 @@
import { defineConfig } from "electron-vite" import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite" import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const channel = (() => { const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL const raw = process.env.OPENCODE_CHANNEL
@@ -7,6 +8,10 @@ const channel = (() => {
return "dev" return "dev"
})() })()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
export default defineConfig({ export default defineConfig({
main: { main: {
define: { define: {
@@ -16,7 +21,33 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { index: "src/main/index.ts" }, input: { index: "src/main/index.ts" },
}, },
externalizeDeps: { include: [nodePtyPkg] },
}, },
plugins: [
{
name: "opencode:node-pty-narrower",
enforce: "pre",
resolveId(s) {
if (s === "@lydell/node-pty") return nodePtyPkg
},
},
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
async writeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (!l.endsWith(".wasm")) continue
await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
}, },
preload: { preload: {
build: { build: {
+23 -12
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.4.0", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
@@ -13,7 +13,7 @@
"typecheck": "tsgo -b", "typecheck": "tsgo -b",
"predev": "bun ./scripts/predev.ts", "predev": "bun ./scripts/predev.ts",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"prebuild": "bun ./scripts/copy-icons.ts", "prebuild": "bun ./scripts/prebuild.ts",
"build": "electron-vite build", "build": "electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"package": "electron-builder --config electron-builder.config.ts", "package": "electron-builder --config electron-builder.config.ts",
@@ -24,31 +24,42 @@
}, },
"main": "./out/main/index.js", "main": "./out/main/index.js",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
"electron-store": "^10", "electron-store": "^10",
"electron-updater": "^6", "electron-updater": "^6",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15", "marked": "^15"
"solid-js": "catalog:",
"tree-kill": "^1.2.2"
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "40.4.1", "electron": "40.4.1",
"electron-builder": "^26", "electron-builder": "^26",
"electron-vite": "^5", "electron-vite": "^5",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "catalog:" "vite": "catalog:",
"zod-openapi": "5.4.6"
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
"@lydell/node-pty-win32-x64": "1.2.0-beta.10"
} }
} }
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`cd ../opencode && bun script/build-node.ts`
+1 -13
View File
@@ -1,17 +1,5 @@
import { $ } from "bun" import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
const RUST_TARGET = Bun.env.RUST_TARGET await $`cd ../opencode && bun script/build-node.ts`
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
+1 -17
View File
@@ -1,25 +1,9 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun"
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
const channel = resolveChannel() await import("./prebuild")
await $`bun ./scripts/copy-icons.ts ${channel}`
const pkg = await Bun.file("./package.json").json() const pkg = await Bun.file("./package.json").json()
pkg.version = Script.version pkg.version = Script.version
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`) console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
await $`rm -rf ${dir}`
-283
View File
@@ -1,283 +0,0 @@
import { execFileSync, spawn } from "node:child_process"
import { EventEmitter } from "node:events"
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import readline from "node:readline"
import { fileURLToPath } from "node:url"
import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
const CLI_BINARY_NAME = "opencode"
export type ServerConfig = {
hostname?: string
port?: number
}
export type Config = {
server?: ServerConfig
}
export type TerminatedPayload = { code: number | null; signal: number | null }
export type CommandEvent =
| { type: "stdout"; value: string }
| { type: "stderr"; value: string }
| { type: "error"; value: string }
| { type: "terminated"; value: TerminatedPayload }
| { type: "sqlite"; value: SqliteMigrationProgress }
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
const root = dirname(fileURLToPath(import.meta.url))
export function getSidecarPath() {
const suffix = process.platform === "win32" ? ".exe" : ""
const path = app.isPackaged
? join(process.resourcesPath, `opencode-cli${suffix}`)
: join(root, "../../resources", `opencode-cli${suffix}`)
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
return path
}
export async function getConfig(): Promise<Config | null> {
const { events } = spawnCommand("debug config", {})
let output = ""
await new Promise<void>((resolve) => {
events.on("stdout", (line: string) => {
output += line
})
events.on("stderr", (line: string) => {
output += line
})
events.on("terminated", () => resolve())
events.on("error", () => resolve())
})
try {
return JSON.parse(output) as Config
} catch {
return null
}
}
export async function installCli(): Promise<string> {
if (process.platform === "win32") {
throw new Error("CLI installation is only supported on macOS & Linux")
}
const sidecar = getSidecarPath()
const scriptPath = join(app.getAppPath(), "install")
const script = readFileSync(scriptPath, "utf8")
const tempScript = join(tmpdir(), "opencode-install.sh")
writeFileSync(tempScript, script, "utf8")
chmodSync(tempScript, 0o755)
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
return await new Promise<string>((resolve, reject) => {
cmd.on("exit", (code: number | null) => {
try {
unlinkSync(tempScript)
} catch {}
if (code === 0) {
const installPath = getCliInstallPath()
if (installPath) return resolve(installPath)
return reject(new Error("Could not determine install path"))
}
reject(new Error("Install script failed"))
})
})
}
export function syncCli() {
if (!app.isPackaged) return
const installPath = getCliInstallPath()
if (!installPath) return
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
const cli = parseVersion(version)
const appVersion = parseVersion(app.getVersion())
if (!cli || !appVersion) return
if (compareVersions(cli, appVersion) >= 0) return
void installCli().catch(() => undefined)
}
export function serve(hostname: string, port: number, password: string) {
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
const env = {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
}
return spawnCommand(args, env)
}
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Spawning command with args: ${args}`)
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const env = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const shell = process.platform === "win32" ? null : getUserShell()
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})
console.log(`[cli] Spawned process with PID: ${child.pid}`)
const events = new EventEmitter()
const exit = new Promise<TerminatedPayload>((resolve) => {
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
resolve({ code: code ?? null, signal: null })
})
child.on("error", (error: Error) => {
console.error(`[cli] Process error: ${error.message}`)
events.emit("error", error.message)
})
})
const stdout = child.stdout
const stderr = child.stderr
if (stdout) {
readline.createInterface({ input: stdout }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stdout", `${line}\n`)
})
}
if (stderr) {
readline.createInterface({ input: stderr }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stderr", `${line}\n`)
})
}
exit.then((payload) => {
events.emit("terminated", payload)
})
const kill = () => {
if (!child.pid) return
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
if (!stripped) return false
if (stripped === "done") {
events.emit("sqlite", { type: "Done" })
return true
}
const value = Number.parseInt(stripped, 10)
if (!Number.isNaN(value)) {
events.emit("sqlite", { type: "InProgress", value })
return true
}
return false
}
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
const script = [
"set -e",
'BIN="$HOME/.opencode/bin/opencode"',
'if [ ! -x "$BIN" ]; then',
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
"fi",
`${envPrefix(env)} exec "$BIN" ${args}`,
].join("\n")
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
}
if (process.platform === "win32") {
const sidecar = getSidecarPath()
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
return { cmd: sidecar, cmdArgs: args.split(" ") }
}
const sidecar = getSidecarPath()
const user = shell || getUserShell()
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
return { cmd: user, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
return entries.join(" ")
}
function shellEscape(input: string) {
if (!input) return "''"
return `'${input.replace(/'/g, `'"'"'`)}'`
}
function getCliInstallPath() {
const home = process.env.HOME
if (!home) return null
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
}
function isWslEnabled() {
return store.get(WSL_ENABLED_KEY) === true
}
function parseVersion(value: string) {
const parts = value
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10))
if (parts.some((part) => Number.isNaN(part))) return null
return parts
}
function compareVersions(a: number[], b: number[]) {
const len = Math.max(a.length, b.length)
for (let i = 0; i < len; i += 1) {
const left = a[i] ?? 0
const right = b[i] ?? 0
if (left > right) return 1
if (left < right) return -1
}
return 0
}
+22
View File
@@ -5,3 +5,25 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv
} }
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}
+10 -22
View File
@@ -11,6 +11,8 @@ import pkg from "electron-updater"
import contextMenu from "electron-context-menu" import contextMenu from "electron-context-menu"
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const APP_NAMES: Record<string, string> = { const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev", dev: "OpenCode Dev",
beta: "OpenCode Beta", beta: "OpenCode Beta",
@@ -27,8 +29,6 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import type { CommandChild } from "./cli"
import { installCli, syncCli } from "./cli"
import { CHANNEL, UPDATER_ENABLED } from "./constants" import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging" import { initLogging } from "./logging"
@@ -36,12 +36,13 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter() const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" } let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null let server: Server.Listener | null = null
const loadingComplete = defer<void>() const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = [] const pendingDeepLinks: string[] = []
@@ -96,11 +97,9 @@ function setupApp() {
} }
void app.whenReady().then(async () => { void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode") app.setAsDefaultProtocolClient("opencode")
setDockIcon() setDockIcon()
setupAutoUpdater() setupAutoUpdater()
syncCli()
await initialize() await initialize()
}) })
} }
@@ -134,8 +133,8 @@ async function initialize() {
const password = randomUUID() const password = randomUUID()
logger.log("spawning sidecar", { url }) logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password) const { listener, health } = await spawnLocalServer(hostname, port, password)
sidecar = child server = listener
serverReady.resolve({ serverReady.resolve({
url, url,
username: "opencode", username: "opencode",
@@ -145,7 +144,7 @@ async function initialize() {
const loadingTask = (async () => { const loadingTask = (async () => {
logger.log("sidecar connection started", { url }) logger.log("sidecar connection started", { url })
events.on("sqlite", (progress: SqliteMigrationProgress) => { initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" }) setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress) if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
@@ -198,9 +197,6 @@ function wireMenu() {
if (!mainWindow) return if (!mainWindow) return
createMenu({ createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
installCli: () => {
void installCli()
},
checkForUpdates: () => { checkForUpdates: () => {
void checkForUpdates(true) void checkForUpdates(true)
}, },
@@ -215,7 +211,6 @@ function wireMenu() {
registerIpcHandlers({ registerIpcHandlers({
killSidecar: () => killSidecar(), killSidecar: () => killSidecar(),
installCli: async () => installCli(),
awaitInitialization: async (sendStep) => { awaitInitialization: async (sendStep) => {
sendStep(initStep) sendStep(initStep)
const listener = (step: InitStep) => sendStep(step) const listener = (step: InitStep) => sendStep(step)
@@ -247,16 +242,9 @@ registerIpcHandlers({
}) })
function killSidecar() { function killSidecar() {
if (!sidecar) return if (!server) return
const pid = sidecar.pid server.stop()
sidecar.kill() server = null
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
} }
function ensureLoopbackNoProxy() { function ensureLoopbackNoProxy() {
@@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
type Deps = { type Deps = {
killSidecar: () => void killSidecar: () => void
installCli: () => Promise<string>
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData> awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null> | string | null getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -34,7 +33,6 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) { export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("install-cli", () => deps.installCli())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step) const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send) return deps.awaitInitialization(send)
@@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
type Deps = { type Deps = {
trigger: (id: string) => void trigger: (id: string) => void
installCli: () => void
checkForUpdates: () => void checkForUpdates: () => void
reload: () => void reload: () => void
relaunch: () => void relaunch: () => void
@@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED, enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(), click: () => deps.checkForUpdates(),
}, },
{
label: "Install CLI...",
click: () => deps.installCli(),
},
{ {
label: "Reload Webview", label: "Reload Webview",
click: () => deps.reload(), click: () => deps.reload(),
+30 -16
View File
@@ -1,5 +1,6 @@
import { serve, type CommandChild } from "./cli" import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { store } from "./store" import { store } from "./store"
export type WslConfig = { enabled: boolean } export type WslConfig = { enabled: boolean }
@@ -29,8 +30,16 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled) store.set(WSL_ENABLED_KEY, config.enabled)
} }
export function spawnLocalServer(hostname: string, port: number, password: string) { export async function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password) prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
})
const wait = (async () => { const wait = (async () => {
const url = `http://${hostname}:${port}` const url = `http://${hostname}:${port}`
@@ -42,19 +51,26 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
} }
} }
const terminated = async () => { await ready()
const payload = await exit
throw new Error(
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
payload.signal ?? "unknown"
})`,
)
}
await Promise.race([ready(), terminated()])
})() })()
return { child, health: { wait }, events } return { listener, health: { wait } }
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: app.getPath("userData"),
}
Object.assign(process.env, env)
} }
export async function checkHealth(url: string, password?: string | null): Promise<boolean> { export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -82,5 +98,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
return false return false
} }
} }
export type { CommandChild }
+13 -13
View File
@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process" import { spawnSync } from "node:child_process"
import { basename } from "node:path" import { basename } from "node:path"
const SHELL_ENV_TIMEOUT = 5_000 const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" } type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
@@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) {
return env return env
} }
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe { function probe(shell: string, mode: "-il" | "-l"): Probe {
const out = spawnSync(shell, [mode, "-c", "env -0"], { const out = spawnSync(shell, [mode, "-c", "env -0"], {
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
timeout: SHELL_ENV_TIMEOUT, timeout: TIMEOUT,
windowsHide: true, windowsHide: true,
}) })
const err = out.error as NodeJS.ErrnoException | undefined const err = out.error as NodeJS.ErrnoException | undefined
if (err) { if (err) {
if (err.code === "ETIMEDOUT") return { type: "Timeout" } if (err.code === "ETIMEDOUT") return { type: "Timeout" }
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`) console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
return { type: "Unavailable" } return { type: "Unavailable" }
} }
if (out.status !== 0) { if (out.status !== 0) {
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`) console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
return { type: "Unavailable" } return { type: "Unavailable" }
} }
const env = parseShellEnv(out.stdout) const env = parseShellEnv(out.stdout)
if (Object.keys(env).length === 0) { if (Object.keys(env).length === 0) {
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`) console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
return { type: "Unavailable" } return { type: "Unavailable" }
} }
@@ -56,27 +56,27 @@ export function isNushell(shell: string) {
export function loadShellEnv(shell: string) { export function loadShellEnv(shell: string) {
if (isNushell(shell)) { if (isNushell(shell)) {
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`) console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
return null return null
} }
const interactive = probeShellEnv(shell, "-il") const interactive = probe(shell, "-il")
if (interactive.type === "Loaded") { if (interactive.type === "Loaded") {
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value return interactive.value
} }
if (interactive.type === "Timeout") { if (interactive.type === "Timeout") {
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`) console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
return null return null
} }
const login = probeShellEnv(shell, "-l") const login = probe(shell, "-l")
if (login.type === "Loaded") { if (login.type === "Loaded") {
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value return login.value
} }
console.warn(`[cli] Falling back to app environment: ${shell}`) console.warn(`[server] Falling back to app environment: ${shell}`)
return null return null
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.4.0", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
if (!releaseId) throw new Error("OPENCODE_RELEASE is required") if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION const version = process.env.OPENCODE_VERSION
if (!releaseId) throw new Error("OPENCODE_VERSION is required") if (!version) throw new Error("OPENCODE_VERSION is required")
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
@@ -54,7 +54,10 @@ const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset])) const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
const latestAsset = assetByName.get("latest.json") const latestAsset = assetByName.get("latest.json")
if (!latestAsset) throw new Error("latest.json asset not found") if (!latestAsset) {
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
}
const latestRes = await fetch(latestAsset.url, { const latestRes = await fetch(latestAsset.url, {
headers: { headers: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.0", "version": "1.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.4.0" version = "1.4.3"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.0", "version": "1.4.3",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.4.0", "version": "1.4.3",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -43,7 +43,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.79.0", "@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -106,7 +106,7 @@
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@lydell/node-pty": "1.2.0-beta.10", "@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
@@ -137,7 +137,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.0.0", "gitlab-ai-provider": "6.4.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
+7 -16
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun"
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
@@ -9,18 +8,11 @@ import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..") const dir = path.resolve(__dirname, "..")
const root = path.resolve(dir, "../..")
function linker(): "hoisted" | "isolated" {
// jsonc-parser is only declared in packages/opencode, so its install location
// tells us whether Bun used a hoisted or isolated workspace layout.
if (fs.existsSync(path.join(dir, "node_modules", "jsonc-parser"))) return "isolated"
if (fs.existsSync(path.join(root, "node_modules", "jsonc-parser"))) return "hoisted"
throw new Error("Could not detect Bun linker from jsonc-parser")
}
process.chdir(dir) process.chdir(dir)
await import("./generate.ts")
// Load migrations from migration directories // Load migrations from migration directories
const migrationDirs = ( const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), { await fs.promises.readdir(path.join(dir, "migration"), {
@@ -51,21 +43,20 @@ const migrations = await Promise.all(
) )
console.log(`Loaded ${migrations.length} migrations`) console.log(`Loaded ${migrations.length} migrations`)
const link = linker()
await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/node-pty@1.2.0-beta.10`
await Bun.build({ await Bun.build({
target: "node", target: "node",
entrypoints: ["./src/node.ts"], entrypoints: ["./src/node.ts"],
outdir: "./dist", outdir: "./dist/node",
format: "esm", format: "esm",
sourcemap: "linked", sourcemap: "linked",
external: ["jsonc-parser"], external: ["jsonc-parser", "@lydell/node-pty"],
define: { define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_CHANNEL: `'${Script.channel}'`, OPENCODE_CHANNEL: `'${Script.channel}'`,
}, },
files: {
"opencode-web-ui.gen.ts": "",
},
}) })
console.log("Build complete") console.log("Build complete")
+2 -15
View File
@@ -12,24 +12,11 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir) process.chdir(dir)
await import("./generate.ts")
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import pkg from "../package.json" import pkg from "../package.json"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
// Load migrations from migration directories // Load migrations from migration directories
const migrationDirs = ( const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), { await fs.promises.readdir(path.join(dir, "migration"), {
+23
View File
@@ -0,0 +1,23 @@
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
+35 -3
View File
@@ -23,7 +23,7 @@ export namespace Foo {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError> readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -219,11 +219,11 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Instruction``session/instruction.ts` - [x] `Instruction``session/instruction.ts`
- [x] `Provider``provider/provider.ts` - [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts` - [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
Still open: Still open:
- [ ] `SessionTodo``session/todo.ts` - [x] `SessionTodo``session/todo.ts`
- [ ] `ShareNext``share/share-next.ts`
- [ ] `SyncEvent``sync/index.ts` - [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts` - [ ] `Workspace``control-plane/workspace.ts`
@@ -308,3 +308,35 @@ Current raw fs users that will convert during tool migration:
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer - [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise - [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code - [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
For each service, the migration is roughly:
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
### Pitfalls
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
- **`Effect.tryPromise``yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
### Migration log
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
+1 -4
View File
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.kv.get`, `set`, `ready` - `api.kv.get`, `set`, `ready`
- `api.state` - `api.state`
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)` - `api.client`
- `api.event.on(type, handler)` - `api.event.on(type, handler)`
- `api.renderer` - `api.renderer`
- `api.slots.register(plugin)` - `api.slots.register(plugin)`
@@ -270,7 +270,6 @@ Command behavior:
- `provider` - `provider`
- `path.{state,config,worktree,directory}` - `path.{state,config,worktree,directory}`
- `vcs?.branch` - `vcs?.branch`
- `workspace.list()` / `workspace.get(workspaceID)`
- `session.count()` - `session.count()`
- `session.diff(sessionID)` - `session.diff(sessionID)`
- `session.todo(sessionID)` - `session.todo(sessionID)`
@@ -282,8 +281,6 @@ Command behavior:
- `lsp()` - `lsp()`
- `mcp()` - `mcp()`
- `api.client` always reflects the current runtime client. - `api.client` always reflects the current runtime client.
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
- `api.renderer` exposes the raw `CliRenderer`. - `api.renderer` exposes the raw `CliRenderer`.
+2 -19
View File
@@ -1,4 +1,4 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
HttpClient, HttpClient,
@@ -181,7 +181,7 @@ export namespace Account {
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError> readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service, Service,
@@ -461,28 +461,11 @@ export namespace Account {
return Option.getOrUndefined(await runPromise((service) => service.active())) return Option.getOrUndefined(await runPromise((service) => service.active()))
} }
export async function list(): Promise<Info[]> {
return runPromise((service) => service.list())
}
export async function activeOrg(): Promise<ActiveOrg | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> { export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount()) return runPromise((service) => service.orgsByAccount())
} }
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
return runPromise((service) => service.orgs(accountID))
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) { export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID))) return runPromise((service) => service.use(accountID, Option.some(orgID)))
} }
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t)
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql" import { AccountStateTable, AccountTable } from "./account.sql"
@@ -38,7 +38,7 @@ export namespace AccountRepo {
} }
} }
export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") { export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect( static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo, AccountRepo,
Effect.gen(function* () { Effect.gen(function* () {
+6 -26
View File
@@ -1,42 +1,22 @@
import { Schema } from "effect" import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError" import type * as HttpClientError from "effect/unstable/http/HttpClientError"
import { withStatics } from "@/util/schema" export const AccountID = Schema.String.pipe(Schema.brand("AccountID"))
export const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID> export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe( export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
Schema.brand("OrgID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID> export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe( export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken> export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const RefreshToken = Schema.String.pipe( export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
Schema.brand("RefreshToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken> export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export const DeviceCode = Schema.String.pipe( export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
Schema.brand("DeviceCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode> export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe( export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
Schema.brand("UserCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export type UserCode = Schema.Schema.Type<typeof UserCode> export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({ export class Info extends Schema.Class<Info>("Account")({
+23 -16
View File
@@ -19,7 +19,7 @@ import { Global } from "@/global"
import path from "path" import path from "path"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect" import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -67,7 +67,7 @@ export namespace Agent {
type State = Omit<Interface, "generate"> type State = Omit<Interface, "generate">
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -341,6 +341,10 @@ export namespace Agent {
) )
const existing = yield* InstanceState.useEffect(state, (s) => s.list()) const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = { const params = {
experimental_telemetry: { experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry, isEnabled: cfg.experimental?.openTelemetry,
@@ -350,12 +354,14 @@ export namespace Agent {
}, },
temperature: 0.3, temperature: 0.3,
messages: [ messages: [
...system.map( ...(isOpenaiOauth
(item): ModelMessage => ({ ? []
role: "system", : system.map(
content: item, (item): ModelMessage => ({
}), role: "system",
), content: item,
}),
)),
{ {
role: "user", role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
@@ -369,13 +375,12 @@ export namespace Agent {
}), }),
} satisfies Parameters<typeof generateObject>[0] } satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over if (isOpenaiOauth) {
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const result = streamObject({ const result = streamObject({
...params, ...params,
providerOptions: ProviderTransform.providerOptions(resolved, { providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false, store: false,
}), }),
onError: () => {}, onError: () => {},
@@ -393,11 +398,13 @@ export namespace Agent {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
Layer.provide(Provider.defaultLayer), layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+2 -2
View File
@@ -1,5 +1,5 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
@@ -49,7 +49,7 @@ export namespace Auth {
readonly remove: (key: string) => Effect.Effect<void, AuthError> readonly remove: (key: string) => Effect.Effect<void, AuthError>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
+2
View File
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
event: [ event: [
{ {
directory?: string directory?: string
project?: string
workspace?: string
payload: any payload: any
}, },
] ]
+12 -4
View File
@@ -1,9 +1,10 @@
import z from "zod" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect" import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event" import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -41,7 +42,7 @@ export namespace Bus {
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -91,8 +92,13 @@ export namespace Bus {
yield* PubSub.publish(s.wildcard, payload) yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: dir, directory: dir,
project: context.project.id,
workspace,
payload, payload,
}) })
}) })
@@ -141,7 +147,7 @@ export namespace Bus {
return () => { return () => {
log.info("unsubscribing", { type }) log.info("unsubscribing", { type })
Effect.runFork(Scope.close(scope, Exit.void)) Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer)))
} }
}) })
} }
@@ -164,6 +170,8 @@ export namespace Bus {
}), }),
) )
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer) const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { spawn } from "child_process" import { spawn } from "child_process"
import { Database } from "../../storage/db" import { Database } from "../../storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite" import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
@@ -74,7 +75,7 @@ const MigrateCommand = cmd({
let last = -1 let last = -1
if (tty) process.stderr.write("\x1b[?25l") if (tty) process.stderr.write("\x1b[?25l")
try { try {
const stats = await JsonMigration.run(sqlite, { const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
progress: (event) => { progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100) const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return if (percent === last) return
+10 -7
View File
@@ -1,5 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { basename } from "path" import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent" import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider" import { Provider } from "../../../provider/provider"
import { Session } from "../../../session" import { Session } from "../../../session"
@@ -157,14 +158,16 @@ async function createToolContext(agent: Agent.Info) {
agent: agent.name, agent: agent.name,
abort: new AbortController().signal, abort: new AbortController().signal,
messages: [], messages: [],
metadata: () => {}, metadata: () => Effect.void,
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) { ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) { return Effect.sync(() => {
const rule = Permission.evaluate(req.permission, pattern, ruleset) for (const pattern of req.patterns) {
if (rule.action === "deny") { const rule = Permission.evaluate(req.permission, pattern, ruleset)
throw new Permission.DeniedError({ ruleset }) if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
}
} }
} })
}, },
} }
} }
+10 -5
View File
@@ -21,6 +21,7 @@ import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { Session } from "../../session" import { Session } from "../../session"
import type { SessionID } from "../../session/schema" import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema"
@@ -28,6 +29,7 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt" import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process" import { Process } from "@/util/process"
@@ -257,7 +259,9 @@ export const GithubInstallCommand = cmd({
} }
// Get repo info // Get repo info
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info) const parsed = parseGitHubRemote(info)
if (!parsed) { if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -496,20 +500,21 @@ export const GithubRunCommand = cmd({
: "issue" : "issue"
: undefined : undefined
const gitText = async (args: string[]) => { const gitText = async (args: string[]) => {
const result = await Git.run(args, { cwd: Instance.worktree }) const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
} }
return result.text().trim() return result.text().trim()
} }
const gitRun = async (args: string[]) => { const gitRun = async (args: string[]) => {
const result = await Git.run(args, { cwd: Instance.worktree }) const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
} }
return result return result
} }
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree }) const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const commitChanges = async (summary: string, actor?: string) => { const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary] const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -559,7 +564,7 @@ export const GithubRunCommand = cmd({
shareId = await (async () => { shareId = await (async () => {
if (share === false) return if (share === false) return
if (!share && repoData.data.private) return if (!share && repoData.data.private) return
await Session.share(session.id) await SessionShare.share(session.id)
return session.id.slice(-8) return session.id.slice(-8)
})() })()
console.log("opencode session", session.id) console.log("opencode session", session.id)
+3 -2
View File
@@ -10,6 +10,7 @@ import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next" import { ShareNext } from "../../share/share-next"
import { EOL } from "os" import { EOL } from "os"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { AppRuntime } from "@/effect/app-runtime"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData = export type ShareData =
@@ -100,7 +101,7 @@ export const ImportCommand = cmd({
if (isUrl) { if (isUrl) {
const slug = parseShareUrl(args.file) const slug = parseShareUrl(args.file)
if (!slug) { if (!slug) {
const baseUrl = await ShareNext.url() const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`) process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL) process.stdout.write(EOL)
return return
@@ -108,7 +109,7 @@ export const ImportCommand = cmd({
const parsed = new URL(args.file) const parsed = new URL(args.file)
const baseUrl = parsed.origin const baseUrl = parsed.origin
const req = await ShareNext.request() const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug) const dataPath = req.api.data(slug)
+1
View File
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId, clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret, clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope, scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
}, },
{ {
onRedirect: async () => {}, onRedirect: async () => {},
+18 -7
View File
@@ -1,5 +1,6 @@
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Process } from "@/util/process" import { Process } from "@/util/process"
@@ -67,19 +68,29 @@ export const PrCommand = cmd({
const remoteName = forkOwner const remoteName = forkOwner
// Check if remote already exists // Check if remote already exists
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim() const remotes = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
if (!remotes.split("\n").includes(remoteName)) { if (!remotes.split("\n").includes(remoteName)) {
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { await AppRuntime.runPromise(
cwd: Instance.worktree, Git.Service.use((git) =>
}) git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
}),
),
)
UI.println(`Added fork remote: ${remoteName}`) UI.println(`Added fork remote: ${remoteName}`)
} }
// Set upstream to the fork so pushes go there // Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName const headRefName = prInfo.headRefName
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { await AppRuntime.runPromise(
cwd: Instance.worktree, Git.Service.use((git) =>
}) git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
}),
),
)
} }
// Check for opencode session link in PR body // Check for opencode session link in PR body
+2 -2
View File
@@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { EOL } from "os" import { EOL } from "os"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
@@ -680,7 +680,7 @@ export const RunCommand = cmd({
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
return Server.Default().fetch(request) return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch }) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk) await execute(sdk)
+33 -49
View File
@@ -14,7 +14,6 @@ import {
batch, batch,
Show, Show,
on, on,
onCleanup,
} from "solid-js" } from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component" import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
@@ -35,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -54,7 +54,6 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args" import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
@@ -216,27 +215,29 @@ export function tui(input: {
headers={input.headers} headers={input.headers}
events={input.events} events={input.events}
> >
<SyncProvider> <ProjectProvider>
<ThemeProvider mode={mode}> <SyncProvider>
<LocalProvider> <ThemeProvider mode={mode}>
<KeybindProvider> <LocalProvider>
<PromptStashProvider> <KeybindProvider>
<DialogProvider> <PromptStashProvider>
<CommandProvider> <DialogProvider>
<FrecencyProvider> <CommandProvider>
<PromptHistoryProvider> <FrecencyProvider>
<PromptRefProvider> <PromptHistoryProvider>
<App onSnapshot={input.onSnapshot} /> <PromptRefProvider>
</PromptRefProvider> <App onSnapshot={input.onSnapshot} />
</PromptHistoryProvider> </PromptRefProvider>
</FrecencyProvider> </PromptHistoryProvider>
</CommandProvider> </FrecencyProvider>
</DialogProvider> </CommandProvider>
</PromptStashProvider> </DialogProvider>
</KeybindProvider> </PromptStashProvider>
</LocalProvider> </KeybindProvider>
</ThemeProvider> </LocalProvider>
</SyncProvider> </ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider> </SDKProvider>
</TuiConfigProvider> </TuiConfigProvider>
</RouteProvider> </RouteProvider>
@@ -260,6 +261,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const kv = useKV() const kv = useKV()
const command = useCommandDialog() const command = useCommandDialog()
const keybind = useKeybind() const keybind = useKeybind()
const event = useEvent()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const themeState = useTheme() const themeState = useTheme()
@@ -283,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
route, route,
routes, routes,
bump: () => setRouteRev((x) => x + 1), bump: () => setRouteRev((x) => x + 1),
event,
sdk, sdk,
sync, sync,
theme: themeState, theme: themeState,
@@ -461,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.replace(() => <DialogSessionList />) dialog.replace(() => <DialogSessionList />)
}, },
}, },
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{ {
title: "New session", title: "New session",
suggested: route.data.type === "session", suggested: route.data.type === "session",
@@ -491,12 +478,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const current = promptRef.current const current = promptRef.current
// Don't require focus - if there's any text, preserve it // Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({ route.navigate({
type: "home", type: "home",
initialPrompt: currentPrompt, initialPrompt: currentPrompt,
workspaceID,
}) })
dialog.clear() dialog.clear()
}, },
@@ -806,11 +790,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}, },
]) ])
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command) command.trigger(evt.properties.command)
}) })
sdk.event.on(TuiEvent.ToastShow.type, (evt) => { event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({ toast.show({
title: evt.properties.title, title: evt.properties.title,
message: evt.properties.message, message: evt.properties.message,
@@ -819,14 +803,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({ route.navigate({
type: "session", type: "session",
sessionID: evt.properties.sessionID, sessionID: evt.properties.sessionID,
}) })
}) })
sdk.event.on("session.deleted", (evt) => { event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
toast.show({ toast.show({
@@ -836,7 +820,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
} }
}) })
sdk.event.on("session.error", (evt) => { event.on("session.error", (evt) => {
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error) const message = errorMessage(error)
@@ -848,7 +832,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
sdk.event.on("installation.update-available", async (evt) => { event.on("installation.update-available", async (evt) => {
const version = evt.properties.version const version = evt.properties.version
const skipped = kv.get("skipped_version") const skipped = kv.get("skipped_version")
@@ -0,0 +1,99 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
const GO_URL = "https://opencode.ai/go"
export type DialogGoUpsellProps = {
onClose?: (dontShowAgain?: boolean) => void
}
function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
open(GO_URL).catch(() => {})
props.onClose?.()
dialog.clear()
}
function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
props.onClose?.(true)
dialog.clear()
}
export function DialogGoUpsell(props: DialogGoUpsellProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal(0)
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === 0 ? 1 : 0))
return
}
if (evt.name !== "return") return
if (selected() === 0) subscribe(props, dialog)
else dismiss(props, dialog)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1} paddingBottom={1}>
<text fg={theme.textMuted}>
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
$5/month.
</text>
<box flexDirection="row" gap={1}>
<Link href={GO_URL} fg={theme.primary} />
</box>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(0)}
onMouseUp={() => subscribe(props, dialog)}
>
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
subscribe
</text>
</box>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(1)}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === 1 ? fg : theme.textMuted}
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
</box>
</box>
)
}
DialogGoUpsell.show = (dialog: DialogContext) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => resolve(false),
)
})
}
@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv" import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const project = useProject()
const keybind = useKeybind() const keybind = useKeybind()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const kv = useKV() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
@@ -31,15 +37,68 @@ export function DialogSessionList() {
}) })
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session) const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
return sessions() return sessions()
.filter((x) => x.parentID === undefined) .filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated) .toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => { .map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
</span>
</>
)
}
} else {
footer = Locale.time(x.time.updated)
}
const date = new Date(x.time.updated) const date = new Date(x.time.updated)
let category = date.toDateString() let category = date.toDateString()
if (category === today) { if (category === today) {
@@ -53,7 +112,7 @@ export function DialogSessionList() {
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
footer: Locale.time(x.time.updated), footer,
gutter: isWorking ? <Spinner /> : undefined, gutter: isWorking ? <Spinner /> : undefined,
} }
}) })
@@ -102,6 +161,15 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)
}, },
}, },
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]} ]}
/> />
) )
@@ -0,0 +1,121 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void create(option.value)
}}
/>
)
}
@@ -1,320 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { setTimeout as sleep } from "node:timers/promises"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = scoped(input.sdk, input.sync, input.workspaceID)
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string) {
if (workspaceID === "__local__") {
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = scoped(sdk, sync, workspaceID)
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => {
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = scoped(sdk, sync, workspace.id)
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: "__local__",
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...sortedFiles.map((item): AutocompleteOption => { ...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}` const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath) const urlObj = pathToFileURL(fullPath)
let filename = item let filename = item
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
const agentStyleId = syntax().getStyleId("extmark.agent")! const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")! const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0 let promptPartTypeId = 0
const event = useEvent()
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
input.insertText(evt.properties.text) input.insertText(evt.properties.text)
setTimeout(() => { setTimeout(() => {
@@ -1,151 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}
@@ -1,11 +1,13 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync" import { useSync } from "./sync"
import { Global } from "@/global" import { Global } from "@/global"
export function useDirectory() { export function useDirectory() {
const project = useProject()
const sync = useSync() const sync = useSync()
return createMemo(() => { return createMemo(() => {
const directory = sync.data.path.directory || process.cwd() const directory = project.instance.path().directory || process.cwd()
const result = directory.replace(Global.Path.home, "~") const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result return result
@@ -0,0 +1,41 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project"
import { useSDK } from "./sdk"
export function useEvent() {
const project = useProject()
const sdk = useSDK()
function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
}
})
}
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
return subscribe((event) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
})
}
return {
subscribe,
on,
}
}
@@ -0,0 +1,106 @@
import { batch } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const sdk = useSDK()
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
},
workspace: {
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
})
async function sync() {
const workspace = store.workspace.current
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("project", "id", project.data?.id)
})
}
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace.current
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
},
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
},
sync,
}
},
})
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = { export type HomeRoute = {
type: "home" type: "home"
initialPrompt?: PromptInfo initialPrompt?: PromptInfo
workspaceID?: string
} }
export type SessionRoute = { export type SessionRoute = {
@@ -1,10 +1,11 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = { export type EventSource = {
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void> subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
} }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let sdk = createSDK() let sdk = createSDK()
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }> event: GlobalEvent
}>() }>()
let queue: Event[] = [] let queue: GlobalEvent[] = []
let timer: Timer | undefined let timer: Timer | undefined
let last = 0 let last = 0
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Batch all event emissions so all store updates result in a single render // Batch all event emissions so all store updates result in a single render
batch(() => { batch(() => {
for (const event of events) { for (const event of events) {
emitter.emit(event.type, event) emitter.emit("event", event)
} }
}) })
} }
const handleEvent = (event: Event) => { const handleEvent = (event: GlobalEvent) => {
queue.push(event) queue.push(event)
const elapsed = Date.now() - last const elapsed = Date.now() - last
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
;(async () => { ;(async () => {
while (true) { while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.event.subscribe({}, { signal: ctrl.signal }) const events = await sdk.global.event({ signal: ctrl.signal })
for await (const event of events.stream) { for await (const event of events.stream) {
if (ctrl.signal.aborted) break if (ctrl.signal.aborted) break
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onMount(async () => { onMount(async () => {
if (props.events) { if (props.events) {
const unsub = await props.events.subscribe(props.directory, handleEvent) const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub) onCleanup(unsub)
} else { } else {
startSSE() startSSE()
@@ -19,16 +19,16 @@ import type {
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot" import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit" import { useExit } from "./exit"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, onMount } from "solid-js" import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -75,8 +75,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[]
}>({ }>({
provider_next: { provider_next: {
all: [], all: [],
@@ -104,20 +102,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp_resource: {}, mcp_resource: {},
formatter: [], formatter: [],
vcs: undefined, vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
}) })
const event = useEvent()
const project = useProject()
const sdk = useSDK() const sdk = useSDK()
async function syncWorkspaces() { event.subscribe((event) => {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
}
sdk.event.listen((e) => {
const event = e.details
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
bootstrap() bootstrap()
@@ -344,7 +335,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
case "lsp.updated": { case "lsp.updated": {
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) const workspace = project.workspace.current()
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
break break
} }
@@ -360,25 +352,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() { async function bootstrap() {
console.log("bootstrapping") console.log("bootstrapping")
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session const sessionListPromise = sdk.client.session
.list({ start: start }) .list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console const consoleStatePromise = sdk.client.experimental.console
.get({}, { throwOnError: true }) .get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data)) .then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState) .catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [ const blockingRequests: Promise<unknown>[] = [
providersPromise, providersPromise,
providerListPromise, providerListPromise,
agentsPromise, agentsPromise,
configPromise, configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []), ...(args.continue ? [sessionListPromise] : []),
] ]
@@ -423,18 +418,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all([ Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.experimental.resource
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), .list({ workspace })
sdk.client.session.status().then((x) => { .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data!)) setStore("session_status", reconcile(x.data!))
}), }),
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), project.workspace.sync(),
syncWorkspaces(),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -449,11 +445,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}) })
} }
onMount(() => {
bootstrap()
})
const fullSyncedSessions = new Set<string>() const fullSyncedSessions = new Set<string>()
createEffect(
on(
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
const result = { const result = {
data: store, data: store,
set: setStore, set: setStore,
@@ -463,6 +465,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() { get ready() {
return store.status !== "loading" return store.status !== "loading"
}, },
get path() {
return project.instance.path()
},
session: { session: {
get(sessionID: string) { get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id) const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -481,11 +486,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }), sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }), sdk.client.session.messages({ sessionID, limit: 100, workspace }),
sdk.client.session.todo({ sessionID }), sdk.client.session.todo({ sessionID, workspace }),
sdk.client.session.diff({ sessionID }), sdk.client.session.diff({ sessionID, workspace }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -503,12 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID) fullSyncedSessions.add(sessionID)
}, },
}, },
workspace: {
get(workspaceID: string) {
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap, bootstrap,
} }
return result return result
@@ -1,6 +1,7 @@
import type { ParsedKey } from "@opentui/core" import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command" import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind" import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route" import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk" import type { useSDK } from "@tui/context/sdk"
@@ -36,6 +37,7 @@ type Input = {
route: ReturnType<typeof useRoute> route: ReturnType<typeof useRoute>
routes: RouteMap routes: RouteMap
bump: () => void bump: () => void
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK> sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
theme: ReturnType<typeof useTheme> theme: ReturnType<typeof useTheme>
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.provider return sync.data.provider
}, },
get path() { get path() {
return sync.data.path return sync.path
}, },
get vcs() { get vcs() {
if (!sync.data.vcs) return if (!sync.data.vcs) return
@@ -144,14 +146,6 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
branch: sync.data.vcs.branch, branch: sync.data.vcs.branch,
} }
}, },
workspace: {
list() {
return sync.data.workspaceList
},
get(workspaceID) {
return sync.workspace.get(workspaceID)
},
},
session: { session: {
count() { count() {
return sync.data.session.length return sync.data.session.length
@@ -342,7 +336,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
get client() { get client() {
return input.sdk.client return input.sdk.client
}, },
event: input.sdk.event, event: input.event,
renderer: input.renderer, renderer: input.renderer,
slots: { slots: {
register() { register() {
@@ -1,6 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js" import { createEffect, createSignal } from "solid-js"
import { Logo } from "../component/logo" import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { Toast } from "../ui/toast" import { Toast } from "../ui/toast"
import { useArgs } from "../context/args" import { useArgs } from "../context/args"
@@ -18,6 +19,7 @@ const placeholder = {
export function Home() { export function Home() {
const sync = useSync() const sync = useSync()
const project = useProject()
const route = useRouteData("home") const route = useRouteData("home")
const promptRef = usePromptRef() const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>() const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -63,11 +65,16 @@ export function Home() {
</box> </box>
<box height={1} minHeight={0} flexShrink={1} /> <box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}> <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}> <TuiPluginRuntime.Slot
name="home_prompt"
mode="replace"
workspace_id={project.workspace.current()}
ref={bind}
>
<Prompt <Prompt
ref={bind} ref={bind}
workspaceID={route.workspaceID} workspaceID={project.workspace.current()}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />} right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
placeholders={placeholder} placeholders={placeholder}
/> />
</TuiPluginRuntime.Slot> </TuiPluginRuntime.Slot>
@@ -15,7 +15,9 @@ import {
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import path from "path" import path from "path"
import { useRoute, useRouteData } from "@tui/context/route" import { useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -83,9 +85,15 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin" import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const context = createContext<{ const context = createContext<{
width: number width: number
sessionID: string sessionID: string
@@ -110,6 +118,8 @@ export function Session() {
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute() const { navigate } = useRoute()
const sync = useSync() const sync = useSync()
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
@@ -149,7 +159,7 @@ export function Session() {
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
@@ -166,10 +176,16 @@ export function Session() {
const providers = createMemo(() => Model.index(sync.data.provider)) const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
createEffect(async () => { createEffect(async () => {
await sync.session await sdk.client.session
.sync(route.sessionID) .get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => { .then(() => {
if (scroll) scroll.scrollBy(100_000) if (scroll) scroll.scrollBy(100_000)
}) })
@@ -183,13 +199,10 @@ export function Session() {
}) })
}) })
const toast = useToast()
const sdk = useSDK()
// Handle initial prompt from fork // Handle initial prompt from fork
let seeded = false let seeded = false
let lastSwitch: string | undefined = undefined let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => { event.on("message.part.updated", (evt) => {
const part = evt.properties.part const part = evt.properties.part
if (part.type !== "tool") return if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return if (part.sessionID !== route.sessionID) return
@@ -218,6 +231,23 @@ export function Session() {
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
if (dialog.stack.length > 0) return
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
if (kv.get(GO_UPSELL_DONT_SHOW)) return
DialogGoUpsell.show(dialog).then((dontShowAgain) => {
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
})
})
// Allow exit when in child session (prompt is hidden) // Allow exit when in child session (prompt is hidden)
const exit = useExit() const exit = useExit()
@@ -1768,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
const workdir = props.input.workdir const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined if (!workdir || workdir === ".") return undefined
const base = sync.data.path.directory const base = sync.path.directory
if (!base) return undefined if (!base) return undefined
const absolute = path.resolve(base, workdir) const absolute = path.resolve(base, workdir)
+4 -12
View File
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource { function createEventSource(client: RpcClient): EventSource {
return { return {
subscribe: async (directory, handler) => { subscribe: async (handler) => {
const id = await client.call("subscribe", { directory }) return client.on<GlobalEvent>("global.event", (e) => {
const unsub = client.on<{ id: string; event: Event }>("event", (e) => { handler(e)
if (e.id === id) {
handler(e.event)
}
}) })
return () => {
unsub()
client.call("unsubscribe", { id })
}
}, },
} }
} }
@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
keybind?: { keybind?: {
keybind?: Keybind.Info keybind?: Keybind.Info
title: string title: string
side?: "left" | "right"
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
}[] }[]
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
gutter?: JSX.Element gutter?: JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext) => void
} }
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.ref?.(ref) props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
return ( return (
<box gap={1} paddingBottom={1}> <box gap={1} paddingBottom={1}>
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box <box
id={JSON.stringify(option.value)} id={JSON.stringify(option.value)}
flexDirection="row" flexDirection="row"
position="relative"
onMouseMove={() => { onMouseMove={() => {
setStore("input", "mouse") setStore("input", "mouse")
}} }}
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingRight={3} paddingRight={3}
gap={1} gap={1}
> >
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option <Option
title={option.title} title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer} footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox> </scrollbox>
</Show> </Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}> <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}> <box
<For each={keybinds()}> paddingRight={2}
{(item) => ( paddingLeft={4}
<text> flexDirection="row"
<span style={{ fg: theme.text }}> justifyContent="space-between"
<b>{item.title}</b>{" "} flexShrink={0}
</span> paddingTop={1}
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span> >
</text> <box flexDirection="row" gap={2}>
)} <For each={left()}>
</For> {(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</box> </box>
</Show> </Show>
</box> </box>
+2 -96
View File
@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import type { Event } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { WorkspaceID } from "@/control-plane/schema"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
await Log.init({ await Log.init({
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
let server: Awaited<ReturnType<typeof Server.listen>> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStreams = new Map<string, AbortController>()
function startEventStream(directory: string) {
const id = crypto.randomUUID()
const abort = new AbortController()
const signal = abort.signal
eventStreams.set(id, abort)
async function run() {
while (!signal.aborted) {
const shouldReconnect = await Instance.provide({
directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
Rpc.emit("event", {
type: "server.connected",
properties: {},
} satisfies Event)
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
signal.removeEventListener("abort", onAbort)
unsub()
resolve(value)
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", {
id,
event: event as Event,
})
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
})
const onAbort = () => {
settle(false)
}
signal.addEventListener("abort", onAbort, { once: true })
}),
}).catch((error) => {
Log.Default.error("event stream subscribe error", {
error: error instanceof Error ? error.message : error,
})
return false
})
if (!shouldReconnect || signal.aborted) {
break
}
if (!signal.aborted) {
await sleep(250)
}
}
}
run().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
return id
}
function stopEventStream(id: string) {
const abortController = eventStreams.get(id)
if (!abortController) return
abortController.abort()
eventStreams.delete(id)
}
export const rpc = { export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) { async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers } const headers = { ...input.headers }
@@ -138,7 +54,7 @@ export const rpc = {
headers, headers,
body: input.body, body: input.body,
}) })
const response = await Server.Default().fetch(request) const response = await Server.Default().app.fetch(request)
const body = await response.text() const body = await response.text()
return { return {
status: response.status, status: response.status,
@@ -167,19 +83,9 @@ export const rpc = {
async reload() { async reload() {
await Config.invalidate(true) await Config.invalidate(true)
}, },
async subscribe(input: { directory: string | undefined }) {
return startEventStream(input.directory || process.cwd())
},
async unsubscribe(input: { id: string }) {
stopEventStream(input.id)
},
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
for (const id of [...eventStreams.keys()]) {
stopEventStream(id)
}
await Instance.disposeAll() await Instance.disposeAll()
if (server) await server.stop(true) if (server) await server.stop(true)
}, },
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { UI } from "../ui" import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { Global } from "../../global" import { Global } from "../../global"
import fs from "fs/promises" import fs from "fs/promises"
@@ -57,7 +58,7 @@ export const UninstallCommand = {
UI.empty() UI.empty()
prompts.intro("Uninstall OpenCode") prompts.intro("Uninstall OpenCode")
const method = await Installation.method() const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
prompts.log.info(`Installation method: ${method}`) prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method) const targets = await collectRemovalTargets(args, method)
+8 -3
View File
@@ -1,6 +1,7 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { UI } from "../ui" import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation" import { Installation } from "../../installation"
export const UpgradeCommand = { export const UpgradeCommand = {
@@ -24,7 +25,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
prompts.intro("Upgrade") prompts.intro("Upgrade")
const detectedMethod = await Installation.method() const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const method = (args.method as Installation.Method) ?? detectedMethod const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") { if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`) prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -42,7 +43,9 @@ export const UpgradeCommand = {
} }
} }
prompts.log.info("Using method: " + method) prompts.log.info("Using method: " + method)
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest() const target = args.target
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
if (Installation.VERSION === target) { if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -53,7 +56,9 @@ export const UpgradeCommand = {
prompts.log.info(`From ${Installation.VERSION}${target}`) prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Upgrading...") spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err) const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
(err) => err,
)
if (err) { if (err) {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) {
+4 -3
View File
@@ -1,12 +1,13 @@
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
export async function upgrade() { export async function upgrade() {
const config = await Config.getGlobal() const config = await Config.getGlobal()
const method = await Installation.method() const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await Installation.latest(method).catch(() => {}) const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@@ -25,7 +26,7 @@ export async function upgrade() {
} }
if (method === "unknown") return if (method === "unknown") return
await Installation.upgrade(method, latest) await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
.then(() => Bus.publish(Installation.Event.Updated, { version: latest })) .then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {}) .catch(() => {})
} }
+6 -10
View File
@@ -1,8 +1,9 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
@@ -70,7 +71,7 @@ export namespace Command {
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -79,7 +80,7 @@ export namespace Command {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx) { const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get() const cfg = yield* config.get()
const commands: Record<string, Info> = {} const commands: Record<string, Info> = {}
@@ -140,6 +141,7 @@ export namespace Command {
.map((message) => (message.content.type === "text" ? message.content.text : "")) .map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "", .join("\n") || "",
), ),
Effect.provide(EffectLogger.layer),
), ),
) )
}, },
@@ -186,10 +188,4 @@ export namespace Command {
Layer.provide(MCP.defaultLayer), Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
} }
+102 -37
View File
@@ -37,10 +37,11 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect" import { Duration, Effect, Layer, Option, Context } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm" import { Npm } from "@/npm"
import { InstanceRef } from "@/effect/instance-ref"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -399,6 +400,10 @@ export namespace Config {
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"), scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
}) })
.strict() .strict()
.meta({ .meta({
@@ -786,28 +791,81 @@ export namespace Config {
}) })
export type Layout = z.infer<typeof Layout> export type Layout = z.infer<typeof Layout>
export const Provider = ModelsDev.Provider.partial() export const Model = z
.extend({ .object({
whitelist: z.array(z.string()).optional(), id: z.string(),
blacklist: z.array(z.string()).optional(), name: z.string(),
models: z family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record( .record(
z.string(), z.string(),
ModelsDev.Model.partial().extend({ z
variants: z .object({
.record( disabled: z.boolean().optional().describe("Disable this variant for the model"),
z.string(), })
z .catchall(z.any()),
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
}),
) )
.optional(), .optional()
.describe("Variant-specific configuration"),
})
.partial()
export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z options: z
.object({ .object({
apiKey: z.string().optional(), apiKey: z.string().optional(),
@@ -840,11 +898,14 @@ export namespace Config {
}) })
.catchall(z.any()) .catchall(z.any())
.optional(), .optional(),
models: z.record(z.string(), Model).optional(),
}) })
.partial()
.strict() .strict()
.meta({ .meta({
ref: "ProviderConfig", ref: "ProviderConfig",
}) })
export type Provider = z.infer<typeof Provider> export type Provider = z.infer<typeof Provider>
export const Info = z export const Info = z
@@ -1066,7 +1127,7 @@ export namespace Config {
readonly waitForDependencies: () => Effect.Effect<void> readonly waitForDependencies: () => Effect.Effect<void>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
function globalConfigFile() { function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
@@ -1267,27 +1328,31 @@ export namespace Config {
const consoleManagedProviders = new Set<string>() const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined let activeOrgName: string | undefined
const scope = (source: string): PluginScope => { const scope = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local" if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (Instance.containsPath(source)) return "local" if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global" return "global"
} })
const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => { const track = Effect.fnUntraced(function* (
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
if (!list?.length) return if (!list?.length) return
const hit = kind ?? scope(source) const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([ const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []), ...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })), ...list.map((spec) => ({ spec, source, scope: hit })),
]) ])
result.plugin = plugins.map((item) => item.spec) result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins result.plugin_origins = plugins
} })
const merge = (source: string, next: Info, kind?: PluginScope) => { const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next) result = mergeConfigConcatArrays(result, next)
track(source, next.plugin, kind) return track(source, next.plugin, kind)
} }
for (const [key, value] of Object.entries(auth)) { for (const [key, value] of Object.entries(auth)) {
@@ -1307,16 +1372,16 @@ export namespace Config {
dir: path.dirname(source), dir: path.dirname(source),
source, source,
}) })
merge(source, next, "global") yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url }) log.debug("loaded remote config from well-known", { url })
} }
} }
const global = yield* getGlobal() const global = yield* getGlobal()
merge(Global.Path.config, global, "global") yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) { if (Flag.OPENCODE_CONFIG) {
merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
@@ -1324,7 +1389,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() => for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) { )) {
merge(file, yield* loadFile(file), "local") yield* merge(file, yield* loadFile(file), "local")
} }
} }
@@ -1345,7 +1410,7 @@ export namespace Config {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file) const source = path.join(dir, file)
log.debug(`loading config from ${source}`) log.debug(`loading config from ${source}`)
merge(source, yield* loadFile(source)) yield* merge(source, yield* loadFile(source))
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
result.plugin ??= [] result.plugin ??= []
@@ -1364,7 +1429,7 @@ export namespace Config {
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir)) const list = yield* Effect.promise(() => loadPlugin(dir))
track(dir, list) yield* track(dir, list)
} }
if (process.env.OPENCODE_CONFIG_CONTENT) { if (process.env.OPENCODE_CONFIG_CONTENT) {
@@ -1373,7 +1438,7 @@ export namespace Config {
dir: ctx.directory, dir: ctx.directory,
source, source,
}) })
merge(source, next, "local") yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
@@ -1402,7 +1467,7 @@ export namespace Config {
for (const providerID of Object.keys(next.provider ?? {})) { for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID) consoleManagedProviders.add(providerID)
} }
merge(source, next, "global") yield* merge(source, next, "global")
} }
}).pipe( }).pipe(
Effect.catch((err) => { Effect.catch((err) => {
@@ -1417,7 +1482,7 @@ export namespace Config {
if (existsSync(managedDir)) { if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file) const source = path.join(managedDir, file)
merge(source, yield* loadFile(source), "global") yield* merge(source, yield* loadFile(source), "global")
} }
} }
@@ -10,8 +10,7 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe( export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({ withStatics((schema: typeof workspaceIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id), ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()), zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
})), })),
) )
@@ -0,0 +1,22 @@
import { LocalContext } from "../util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: string
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (err) {
return undefined
}
},
}
+101 -40
View File
@@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors" import { getAdaptor } from "./adaptors"
@@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse" import { parseSSE } from "./sse"
export namespace Workspace { export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export const Event = { export const Event = {
Ready: BusEvent.define( Ready: BusEvent.define(
"workspace.ready", "workspace.ready",
@@ -27,13 +41,9 @@ export namespace Workspace {
message: z.string(), message: z.string(),
}), }),
), ),
Status: BusEvent.define("workspace.status", ConnectionStatus),
} }
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return { return {
id: row.id, id: row.id,
@@ -85,6 +95,9 @@ export namespace Workspace {
}) })
await adaptor.create(config) await adaptor.create(config)
startSync(info)
return info return info
}) })
@@ -92,18 +105,24 @@ export namespace Workspace {
const rows = Database.use((db) => const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
) )
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
} }
export const get = fn(WorkspaceID.zod, async (id) => { export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return if (!row) return
return fromRow(row) const space = fromRow(row)
startSync(space)
return space
}) })
export const remove = fn(WorkspaceID.zod, async (id) => { export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) { if (row) {
stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(row.type) const adaptor = await getAdaptor(row.type)
adaptor.remove(info) adaptor.remove(info)
@@ -111,58 +130,100 @@ export namespace Workspace {
return info return info
} }
}) })
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" }) const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, stop: AbortSignal) { async function workspaceEventLoop(space: Info, signal: AbortSignal) {
while (!stop.aborted) { log.info("starting sync: " + space.id)
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const target = await Promise.resolve(adaptor.target(space)) const target = await adaptor.target(space)
if (target.type === "local") { if (target.type === "local") return
return
}
const baseURL = String(target.url).replace(/\/?$/, "/") const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
setStatus(space.id, "error", String(err))
const res = await fetch(new URL(baseURL + "/event"), { return undefined
method: "GET",
signal: stop,
}) })
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
if (!res.ok || !res.body) { setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
await sleep(1000) await sleep(1000)
continue continue
} }
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
await parseSSE(res.body, stop, (event) => { try {
GlobalBus.emit("event", { if (!event.type.startsWith("server.")) {
directory: space.id, SyncEvent.replay(event)
payload: event, }
}) } catch (err) {
log.warn("failed to replay sync event", {
workspaceID: space.id,
error: err,
})
}
}) })
setStatus(space.id, "disconnected")
// Wait 250ms and retry if SSE connection fails log.info("disconnected to sync: " + space.id)
await sleep(250) await sleep(250)
} }
} }
export function startSyncing(project: Project.Info) { function startSync(space: Info) {
const stop = new AbortController() if (space.type === "worktree") {
const spaces = list(project).filter((space) => space.type !== "worktree") void Filesystem.exists(space.directory!).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
spaces.forEach((space) => { if (aborts.has(space.id)) return
void workspaceEventLoop(space, stop.signal).catch((error) => { const abort = new AbortController()
log.warn("workspace sync listener failed", { aborts.set(space.id, abort)
workspaceID: space.id, setStatus(space.id, "disconnected")
error,
}) void workspaceEventLoop(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
}) })
}) })
}
return { function stopSync(id: WorkspaceID) {
async stop() { aborts.get(id)?.abort()
stop.abort() aborts.delete(id)
}, connections.delete(id)
}
} }
} }
+100
View File
@@ -0,0 +1,100 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Observability } from "./oltp"
import { AppFileSystem } from "@/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
import { Session } from "@/session"
import { SessionStatus } from "@/session/status"
import { SessionRunState } from "@/session/run-state"
import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
Skill.defaultLayer,
Discovery.defaultLayer,
Question.defaultLayer,
Permission.defaultLayer,
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
MCP.defaultLayer,
McpAuth.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
@@ -0,0 +1,9 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
@@ -402,6 +402,7 @@ export const make = Effect.gen(function* () {
const fd = yield* setupFds(command, proc, extra) const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr) const out = setupOutput(command, proc, sout, serr)
let ref = true
return makeHandle({ return makeHandle({
pid: ProcessId(proc.pid!), pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin), stdin: yield* setupStdin(command, proc, sin),
@@ -432,6 +433,18 @@ export const make = Effect.gen(function* () {
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
}) })
}, },
unref: Effect.sync(() => {
if (ref) {
proc.unref()
ref = false
}
return Effect.sync(() => {
if (!ref) {
proc.ref()
ref = true
}
})
}),
}) })
} }
case "PipedCommand": { case "PipedCommand": {
@@ -499,4 +512,3 @@ const rt = lazy(async () => {
type RT = Awaited<ReturnType<typeof rt>> type RT = Awaited<ReturnType<typeof rt>>
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any])) export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))
+6 -2
View File
@@ -1,6 +1,10 @@
import { ServiceMap } from "effect" import { Context } from "effect"
import type { InstanceContext } from "@/project/instance" import type { InstanceContext } from "@/project/instance"
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", { export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined, defaultValue: () => undefined,
}) })
+14 -12
View File
@@ -1,8 +1,10 @@
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect" import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Instance, type InstanceContext } from "@/project/instance" import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context" import { LocalContext } from "@/util/local-context"
import { InstanceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
const TypeId = "~opencode/InstanceState" const TypeId = "~opencode/InstanceState"
@@ -16,10 +18,10 @@ export namespace InstanceState {
try { try {
return Instance.bind(fn) return Instance.bind(fn)
} catch (err) { } catch (err) {
if (!(err instanceof Context.NotFound)) throw err if (!(err instanceof LocalContext.NotFound)) throw err
} }
const fiber = Fiber.getCurrent() const fiber = Fiber.getCurrent()
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
if (!ctx) return fn if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
} }
@@ -28,6 +30,10 @@ export namespace InstanceState {
return (yield* InstanceRef) ?? Instance.current return (yield* InstanceRef) ?? Instance.current
}) })
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory) export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>( export const make = <A, E = never, R = never>(
@@ -42,7 +48,9 @@ export namespace InstanceState {
}), }),
}) })
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory))) const off = registerDisposer((directory) =>
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
)
yield* Effect.addFinalizer(() => Effect.sync(off)) yield* Effect.addFinalizer(() => Effect.sync(off))
return { return {
@@ -73,10 +81,4 @@ export namespace InstanceState {
Effect.gen(function* () { Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory) return yield* ScopedCache.invalidate(self.cache, yield* directory)
}) })
/**
* Effect finalizers run on the fiber scheduler after the original async
* boundary, so ALS reads like Instance.directory can be gone by then.
*/
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
} }
+67
View File
@@ -0,0 +1,67 @@
import { Cause, Effect, Logger, References } from "effect"
import { Log } from "@/util/log"
export namespace EffectLogger {
type Fields = Record<string, unknown>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
}
+32 -25
View File
@@ -1,34 +1,41 @@
import { Layer } from "effect" import { Duration, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability" import { Otlp } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta" import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability { export namespace Observability {
export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT const resource = {
? Layer.empty serviceName: "opencode",
: Otlp.layerJson({ serviceVersion: VERSION,
baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT, attributes: {
loggerMergeWithExisting: false, "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
resource: { "opencode.client": Flag.OPENCODE_CLIENT,
serviceName: "opencode", },
serviceVersion: VERSION, }
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
"opencode.client": Flag.OPENCODE_CLIENT, ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
}, (acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
}, },
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS {} as Record<string, string>,
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( )
(acc, x) => { : undefined
const [key, value] = x.split("=")
acc[key] = value export const layer = !base
return acc ? EffectLogger.layer
}, : Otlp.layerJson({
{} as Record<string, string>, baseUrl: base,
) loggerExportInterval: Duration.seconds(1),
: undefined, loggerMergeWithExisting: true,
}).pipe(Layer.provide(FetchHttpClient.layer)) resource,
headers,
}).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
} }
+9 -7
View File
@@ -1,23 +1,25 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap" import * as Context from "effect/Context"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Context } from "@/util/context" import { LocalContext } from "@/util/local-context"
import { InstanceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./oltp" import { Observability } from "./oltp"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx) const workspaceID = WorkspaceContext.workspaceID
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
} catch (err) { } catch (err) {
if (!(err instanceof Context.NotFound)) throw err if (!(err instanceof LocalContext.NotFound)) throw err
} }
return effect return effect
} }
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap })) const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
+6 -14
View File
@@ -1,10 +1,10 @@
import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
export interface Runner<A, E = never> { export interface Runner<A, E = never> {
readonly state: Runner.State<A, E> readonly state: Runner.State<A, E>
readonly busy: boolean readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E> readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E> readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly cancel: Effect.Effect<void> readonly cancel: Effect.Effect<void>
} }
@@ -20,7 +20,6 @@ export namespace Runner {
interface ShellHandle<A, E> { interface ShellHandle<A, E> {
id: number id: number
fiber: Fiber.Fiber<A, E> fiber: Fiber.Fiber<A, E>
abort: AbortController
} }
interface PendingHandle<A, E> { interface PendingHandle<A, E> {
@@ -100,13 +99,7 @@ export namespace Runner {
}), }),
).pipe(Effect.flatten) ).pipe(Effect.flatten)
const stopShell = (shell: ShellHandle<A, E>) => const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
Effect.gen(function* () {
shell.abort.abort()
const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
})
const ensureRunning = (work: Effect.Effect<A, E>) => const ensureRunning = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect( SynchronizedRef.modifyEffect(
@@ -138,7 +131,7 @@ export namespace Runner {
), ),
) )
const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) => const startShell = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect( SynchronizedRef.modifyEffect(
ref, ref,
Effect.fnUntraced(function* (st) { Effect.fnUntraced(function* (st) {
@@ -153,9 +146,8 @@ export namespace Runner {
} }
yield* busy yield* busy
const id = next() const id = next()
const abort = new AbortController() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) const shell = { id, fiber } satisfies ShellHandle<A, E>
const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
return [ return [
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
+90 -107
View File
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git" import { Git } from "@/git"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, Context } from "effect"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import ignore from "ignore" import ignore from "ignore"
@@ -11,7 +11,6 @@ import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
@@ -338,12 +337,13 @@ export namespace File {
}) => Effect.Effect<string[]> }) => Effect.Effect<string[]>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {} export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service const appFs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() => Effect.fn("File.state")(() =>
@@ -410,6 +410,10 @@ export namespace File {
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
}) })
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* ensure() yield* ensure()
}) })
@@ -417,100 +421,87 @@ export namespace File {
const status = Effect.fn("File.status")(function* () { const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return [] if (Instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => { const diffOutput = yield* gitText([
const diffOutput = ( "-c",
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { "core.fsmonitor=false",
cwd: Instance.directory, "-c",
"core.quotepath=false",
"diff",
"--numstat",
"HEAD",
])
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
}) })
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
} }
}
const untrackedOutput = ( const untrackedOutput = yield* gitText([
await Git.run( "-c",
[ "core.fsmonitor=false",
"-c", "-c",
"core.fsmonitor=false", "core.quotepath=false",
"-c", "ls-files",
"core.quotepath=false", "--others",
"ls-files", "--exclude-standard",
"--others", ])
"--exclude-standard",
],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) { for (const file of untrackedOutput.trim().split("\n")) {
try { const content = yield* appFs
const content = await Filesystem.readText(path.join(Instance.directory, file)) .readFileString(path.join(Instance.directory, file))
changed.push({ .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
path: file, if (content === undefined) continue
added: content.split("\n").length, changed.push({
removed: 0, path: file,
status: "added", added: content.split("\n").length,
}) removed: 0,
} catch { status: "added",
continue })
}
}
} }
}
const deletedOutput = ( const deletedOutput = yield* gitText([
await Git.run( "-c",
[ "core.fsmonitor=false",
"-c", "-c",
"core.fsmonitor=false", "core.quotepath=false",
"-c", "diff",
"core.quotepath=false", "--name-only",
"diff", "--diff-filter=D",
"--name-only", "HEAD",
"--diff-filter=D", ])
"HEAD",
],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) { for (const file of deletedOutput.trim().split("\n")) {
changed.push({ changed.push({
path: file, path: file,
added: 0, added: 0,
removed: 0, removed: 0,
status: "deleted", status: "deleted",
}) })
}
} }
}
return changed.map((item) => { return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return { return {
...item, ...item,
path: path.relative(Instance.directory, full), path: path.relative(Instance.directory, full),
} }
})
}) })
}) })
const read = Effect.fn("File.read")(function* (file: string) { const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(Instance.directory, file) const full = path.join(Instance.directory, file)
@@ -558,27 +549,19 @@ export namespace File {
) )
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
return yield* Effect.promise(async (): Promise<File.Content> => { let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
let diff = ( if (!diff.trim()) {
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
).text() }
if (!diff.trim()) { if (diff.trim()) {
diff = ( const original = yield* git.show(Instance.directory, "HEAD", file)
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { const patch = structuredPatch(file, file, original, content, "old", "new", {
cwd: Instance.directory, context: Infinity,
}) ignoreWhitespace: true,
).text() })
} return { type: "text" as const, content, patch, diff: formatPatch(patch) }
if (diff.trim()) { }
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() return { type: "text" as const, content }
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text", content, patch, diff: formatPatch(patch) }
}
return { type: "text", content }
})
} }
return { type: "text" as const, content } return { type: "text" as const, content }
@@ -660,7 +643,7 @@ export namespace File {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+70
View File
@@ -3,10 +3,17 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
input.signal?.throwIfAborted() input.signal?.throwIfAborted()
} }
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) { export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input) log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
+7 -7
View File
@@ -1,4 +1,4 @@
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect" import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
@@ -34,10 +34,10 @@ export namespace FileTime {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T> readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -46,7 +46,7 @@ export namespace FileTime {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) { const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
return { return {
read: yield* DateTime.nowAsDate, read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
@@ -103,8 +103,8 @@ export namespace FileTime {
) )
}) })
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) { const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
}) })
return Service.of({ read, get, assert, withLock }) return Service.of({ read, get, assert, withLock })
@@ -128,6 +128,6 @@ export namespace FileTime {
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, fn)) return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
} }
} }
+7 -8
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect" import { Cause, Effect, Layer, Scope, Context } from "effect"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
@@ -65,12 +65,13 @@ export namespace FileWatcher {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")( Effect.fn("FileWatcher.state")(
@@ -131,11 +132,9 @@ export namespace FileWatcher {
} }
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() => const result = yield* git.run(["rev-parse", "--git-dir"], {
Git.run(["rev-parse", "--git-dir"], { cwd: Instance.project.worktree,
cwd: Instance.project.worktree, })
}),
)
const vcsDir = const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -161,7 +160,7 @@ export namespace FileWatcher {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+2 -2
View File
@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import * as NFS from "fs/promises" import * as NFS from "fs/promises"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect" import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
@@ -36,7 +36,7 @@ export namespace AppFileSystem {
readonly globMatch: (pattern: string, filepath: string) => boolean readonly globMatch: (pattern: string, filepath: string) => boolean
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
+9 -17
View File
@@ -1,8 +1,7 @@
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import path from "path" import path from "path"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import z from "zod" import z from "zod"
@@ -31,7 +30,7 @@ export namespace Format {
readonly file: (filepath: string) => Effect.Effect<void> readonly file: (filepath: string) => Effect.Effect<void>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -51,6 +50,13 @@ export namespace Format {
formatters[item.name] = item formatters[item.name] = item
} }
for (const [name, item] of Object.entries(cfg.formatter ?? {})) { for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) { if (item.disabled) {
delete formatters[name] delete formatters[name]
continue continue
@@ -186,18 +192,4 @@ export namespace Format {
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() {
return runPromise((s) => s.status())
}
export async function file(filepath: string) {
return runPromise((s) => s.file(filepath))
}
} }
+2 -45
View File
@@ -1,7 +1,6 @@
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Effect, Layer, ServiceMap, Stream } from "effect" import { Effect, Layer, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { makeRuntime } from "@/effect/run-service"
export namespace Git { export namespace Git {
const cfg = [ const cfg = [
@@ -80,7 +79,7 @@ export namespace Git {
return "modified" return "modified"
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -258,46 +257,4 @@ export namespace Git {
) )
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function run(args: string[], opts: Options) {
return runPromise((git) => git.run(args, opts))
}
export async function branch(cwd: string) {
return runPromise((git) => git.branch(cwd))
}
export async function prefix(cwd: string) {
return runPromise((git) => git.prefix(cwd))
}
export async function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
export async function hasHead(cwd: string) {
return runPromise((git) => git.hasHead(cwd))
}
export async function mergeBase(cwd: string, base: string, head?: string) {
return runPromise((git) => git.mergeBase(cwd, base, head))
}
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
return runPromise((git) => git.show(cwd, ref, file, prefix))
}
export async function status(cwd: string) {
return runPromise((git) => git.status(cwd))
}
export async function diff(cwd: string, ref: string) {
return runPromise((git) => git.diff(cwd, ref))
}
export async function stats(cwd: string, ref: string) {
return runPromise((git) => git.stats(cwd, ref))
}
} }

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