Kaynağa Gözat

Merge remote-tracking branch 'origin/dev' into pr-18308

# Conflicts:
#	packages/opencode/src/bun/index.ts
#	packages/opencode/test/bun.test.ts
Dax Raad 3 hafta önce
ebeveyn
işleme
89dcee323c
100 değiştirilmiş dosya ile 2354 ekleme ve 1356 silme
  1. 3 1
      .github/VOUCHED.td
  2. 3 2
      .github/workflows/docs-locale-sync.yml
  3. 4 0
      .github/workflows/nix-hashes.yml
  4. 183 1
      .github/workflows/publish.yml
  5. 0 54
      .github/workflows/sign-cli.yml
  6. 3 0
      .github/workflows/test.yml
  7. 1 0
      .gitignore
  8. 0 34
      .opencode/agent/docs.md
  9. 43 20
      .opencode/command/changelog.md
  10. 47 8
      .opencode/plugins/tui-smoke.tsx
  11. 0 1
      .opencode/tui.json
  12. 0 5
      .signpath/policies/opencode/test-signing.yml
  13. 105 221
      bun.lock
  14. 4 4
      nix/hashes.json
  15. 2 1
      nix/node_modules.nix
  16. 4 0
      nix/opencode.nix
  17. 7 6
      package.json
  18. 75 6
      packages/app/e2e/fixtures.ts
  19. 76 31
      packages/app/e2e/prompt/prompt.spec.ts
  20. 68 0
      packages/app/e2e/session/session-composer-dock.spec.ts
  21. 2 0
      packages/app/e2e/session/session-review.spec.ts
  22. 24 24
      packages/app/e2e/settings/settings.spec.ts
  23. 1 1
      packages/app/index.html
  24. 1 1
      packages/app/package.json
  25. 1 1
      packages/app/script/e2e-local.ts
  26. 12 3
      packages/app/src/app.tsx
  27. 6 2
      packages/app/src/components/prompt-input.tsx
  28. 24 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  29. 26 0
      packages/app/src/components/prompt-input/build-request-parts.ts
  30. 82 60
      packages/app/src/context/global-sdk.tsx
  31. 20 0
      packages/app/src/context/global-sync.tsx
  32. 16 14
      packages/app/src/context/global-sync/bootstrap.ts
  33. 31 16
      packages/app/src/context/layout.tsx
  34. 9 1
      packages/app/src/context/server.tsx
  35. 20 22
      packages/app/src/context/settings.tsx
  36. 51 2
      packages/app/src/pages/session.tsx
  37. 202 82
      packages/app/src/pages/session/composer/session-question-dock.tsx
  38. 145 131
      packages/app/src/pages/session/file-tabs.tsx
  39. 4 0
      packages/app/src/pages/session/review-tab.tsx
  40. 440 368
      packages/app/src/pages/session/use-session-commands.tsx
  41. 1 1
      packages/console/app/package.json
  42. 1 2
      packages/console/app/src/i18n/ar.ts
  43. 1 2
      packages/console/app/src/i18n/br.ts
  44. 1 2
      packages/console/app/src/i18n/da.ts
  45. 1 2
      packages/console/app/src/i18n/de.ts
  46. 2 2
      packages/console/app/src/i18n/en.ts
  47. 1 2
      packages/console/app/src/i18n/es.ts
  48. 1 2
      packages/console/app/src/i18n/fr.ts
  49. 1 2
      packages/console/app/src/i18n/it.ts
  50. 1 2
      packages/console/app/src/i18n/ja.ts
  51. 1 2
      packages/console/app/src/i18n/ko.ts
  52. 1 2
      packages/console/app/src/i18n/no.ts
  53. 1 2
      packages/console/app/src/i18n/pl.ts
  54. 1 2
      packages/console/app/src/i18n/ru.ts
  55. 1 2
      packages/console/app/src/i18n/th.ts
  56. 1 2
      packages/console/app/src/i18n/tr.ts
  57. 2 2
      packages/console/app/src/i18n/zh.ts
  58. 2 2
      packages/console/app/src/i18n/zht.ts
  59. 1 4
      packages/console/app/src/routes/go/index.tsx
  60. 19 20
      packages/console/app/src/routes/zen/util/handler.ts
  61. 3 2
      packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
  62. 2 2
      packages/console/app/src/routes/zen/util/provider/openai.ts
  63. 8 2
      packages/console/app/src/routes/zen/util/provider/provider.ts
  64. 1 1
      packages/console/core/package.json
  65. 39 0
      packages/console/core/script/freeze-workspace.ts
  66. 63 87
      packages/console/core/script/lookup-user.ts
  67. 1 1
      packages/console/core/script/promote-limits.ts
  68. 1 1
      packages/console/core/script/promote-models.ts
  69. 2 2
      packages/console/core/script/update-limits.ts
  70. 2 2
      packages/console/core/script/update-models.ts
  71. 1 0
      packages/console/core/src/model.ts
  72. 4 0
      packages/console/core/src/util/price.ts
  73. 4 4
      packages/console/function/package.json
  74. 1 1
      packages/console/mail/package.json
  75. 23 0
      packages/desktop-electron/electron-builder.config.ts
  76. 3 0
      packages/desktop-electron/icons/README.md
  77. BIN
      packages/desktop-electron/icons/beta/dock.png
  78. BIN
      packages/desktop-electron/icons/dev/dock.png
  79. BIN
      packages/desktop-electron/icons/prod/dock.png
  80. 1 1
      packages/desktop-electron/package.json
  81. 2 1
      packages/desktop-electron/scripts/prepare.ts
  82. 3 0
      packages/desktop-electron/scripts/utils.ts
  83. 10 7
      packages/desktop-electron/src/main/cli.ts
  84. 43 0
      packages/desktop-electron/src/main/shell-env.test.ts
  85. 88 0
      packages/desktop-electron/src/main/shell-env.ts
  86. 2 1
      packages/desktop-electron/src/main/windows.ts
  87. 1 1
      packages/desktop/package.json
  88. 2 1
      packages/desktop/scripts/prepare.ts
  89. 3 0
      packages/desktop/scripts/utils.ts
  90. 4 0
      packages/desktop/src-tauri/tauri.beta.conf.json
  91. 4 0
      packages/desktop/src-tauri/tauri.conf.json
  92. 4 0
      packages/desktop/src-tauri/tauri.prod.conf.json
  93. 1 1
      packages/enterprise/package.json
  94. 6 6
      packages/extensions/zed/extension.toml
  95. 1 1
      packages/function/package.json
  96. 28 26
      packages/opencode/package.json
  97. 15 0
      packages/opencode/script/seed-e2e.ts
  98. 64 0
      packages/opencode/script/upgrade-opentui.ts
  99. 78 6
      packages/opencode/specs/effect-migration.md
  100. 50 17
      packages/opencode/specs/tui-plugins.md

+ 3 - 1
.github/VOUCHED.td

@@ -21,8 +21,10 @@ jayair
 kitlangton
 kommander
 -opencode2026
+-opencodeengineer bot that spams issues
 r44vc0rp
 rekram1-node
+-robinmordasiewicz
 -spider-yamet clawdbot/llm psychosis, spam pinging the team
 thdxr
--OpenCodeEngineer bot that spams issues
+-toastythebot

+ 3 - 2
.github/workflows/docs-locale-sync.yml

@@ -9,7 +9,8 @@ on:
 
 jobs:
   sync-locales:
-    if: github.actor != 'opencode-agent[bot]'
+    if: false
+    #if: github.actor != 'opencode-agent[bot]'
     runs-on: blacksmith-4vcpu-ubuntu-2404
     permissions:
       contents: write
@@ -34,7 +35,7 @@ jobs:
       - name: Compute changed English docs
         id: changes
         run: |
-          FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
+          FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
           if [ -z "$FILES" ]; then
             echo "has_changes=false" >> "$GITHUB_OUTPUT"
             echo "No English docs changed in push range"

+ 4 - 0
.github/workflows/nix-hashes.yml

@@ -17,6 +17,10 @@ on:
       - "patches/**"
       - ".github/workflows/nix-hashes.yml"
 
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   # Native runners required: bun install cross-compilation flags (--os/--cpu)
   # do not produce byte-identical node_modules as native installs.

+ 183 - 1
.github/workflows/publish.yml

@@ -98,15 +98,129 @@ jobs:
       - uses: actions/upload-artifact@v4
         with:
           name: opencode-cli
-          path: packages/opencode/dist
+          path: |
+            packages/opencode/dist/opencode-darwin*
+            packages/opencode/dist/opencode-linux*
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: opencode-cli-windows
+          path: packages/opencode/dist/opencode-windows*
     outputs:
       version: ${{ needs.version.outputs.version }}
 
+  sign-cli-windows:
+    needs:
+      - build-cli
+      - version
+    runs-on: blacksmith-4vcpu-windows-2025
+    if: github.repository == 'anomalyco/opencode'
+    env:
+      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+      AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+      AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
+      AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
+      AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
+    steps:
+      - uses: actions/checkout@v3
+
+      - uses: actions/download-artifact@v4
+        with:
+          name: opencode-cli-windows
+          path: packages/opencode/dist
+
+      - name: Setup git committer
+        id: committer
+        uses: ./.github/actions/setup-git-committer
+        with:
+          opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
+          opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
+
+      - name: Azure login
+        uses: azure/login@v2
+        with:
+          client-id: ${{ env.AZURE_CLIENT_ID }}
+          tenant-id: ${{ env.AZURE_TENANT_ID }}
+          subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
+
+      - uses: azure/artifact-signing-action@v1
+        with:
+          endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
+          signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
+          certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
+          files: |
+            ${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
+            ${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
+            ${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
+          exclude-environment-credential: true
+          exclude-workload-identity-credential: true
+          exclude-managed-identity-credential: true
+          exclude-shared-token-cache-credential: true
+          exclude-visual-studio-credential: true
+          exclude-visual-studio-code-credential: true
+          exclude-azure-cli-credential: false
+          exclude-azure-powershell-credential: true
+          exclude-azure-developer-cli-credential: true
+          exclude-interactive-browser-credential: true
+
+      - name: Verify Windows CLI signatures
+        shell: pwsh
+        run: |
+          $files = @(
+            "${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
+            "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
+            "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
+          )
+
+          foreach ($file in $files) {
+            $sig = Get-AuthenticodeSignature $file
+            if ($sig.Status -ne "Valid") {
+              throw "Invalid signature for ${file}: $($sig.Status)"
+            }
+          }
+
+      - name: Repack Windows CLI archives
+        working-directory: packages/opencode/dist
+        shell: pwsh
+        run: |
+          Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
+          Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
+          Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
+
+      - name: Upload signed Windows CLI release assets
+        if: needs.version.outputs.release != ''
+        shell: pwsh
+        env:
+          GH_TOKEN: ${{ steps.committer.outputs.token }}
+        run: |
+          gh release upload "v${{ needs.version.outputs.version }}" `
+            "${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
+            "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
+            "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
+            --clobber `
+            --repo "${{ needs.version.outputs.repo }}"
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: opencode-cli-signed-windows
+          path: |
+            packages/opencode/dist/opencode-windows-arm64
+            packages/opencode/dist/opencode-windows-x64
+            packages/opencode/dist/opencode-windows-x64-baseline
+
   build-tauri:
     needs:
       - build-cli
       - version
     continue-on-error: false
+    env:
+      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+      AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+      AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
+      AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
+      AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
     strategy:
       fail-fast: false
       matrix:
@@ -152,6 +266,14 @@ jobs:
 
       - uses: ./.github/actions/setup-bun
 
+      - name: Azure login
+        if: runner.os == 'Windows'
+        uses: azure/login@v2
+        with:
+          client-id: ${{ env.AZURE_CLIENT_ID }}
+          tenant-id: ${{ env.AZURE_TENANT_ID }}
+          subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
+
       - uses: actions/setup-node@v4
         with:
           node-version: "24"
@@ -190,6 +312,7 @@ jobs:
         env:
           OPENCODE_VERSION: ${{ needs.version.outputs.version }}
           GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+          OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
           RUST_TARGET: ${{ matrix.settings.target }}
           GH_TOKEN: ${{ github.token }}
           GITHUB_RUN_ID: ${{ github.run_id }}
@@ -246,11 +369,34 @@ jobs:
           APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
           APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
 
+      - name: Verify signed Windows desktop artifacts
+        if: runner.os == 'Windows'
+        shell: pwsh
+        run: |
+          $files = @(
+            "${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
+          )
+          $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
+
+          foreach ($file in $files) {
+            $sig = Get-AuthenticodeSignature $file
+            if ($sig.Status -ne "Valid") {
+              throw "Invalid signature for ${file}: $($sig.Status)"
+            }
+          }
+
   build-electron:
     needs:
       - build-cli
       - version
     continue-on-error: false
+    env:
+      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+      AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+      AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
+      AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
+      AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
     strategy:
       fail-fast: false
       matrix:
@@ -292,6 +438,14 @@ jobs:
 
       - uses: ./.github/actions/setup-bun
 
+      - name: Azure login
+        if: runner.os == 'Windows'
+        uses: azure/login@v2
+        with:
+          client-id: ${{ env.AZURE_CLIENT_ID }}
+          tenant-id: ${{ env.AZURE_TENANT_ID }}
+          subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
+
       - uses: actions/setup-node@v4
         with:
           node-version: "24"
@@ -326,6 +480,7 @@ jobs:
         env:
           OPENCODE_VERSION: ${{ needs.version.outputs.version }}
           OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+          OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
           RUST_TARGET: ${{ matrix.settings.target }}
           GH_TOKEN: ${{ github.token }}
           GITHUB_RUN_ID: ${{ github.run_id }}
@@ -358,6 +513,22 @@ jobs:
         env:
           OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
 
+      - name: Verify signed Windows Electron artifacts
+        if: runner.os == 'Windows'
+        shell: pwsh
+        run: |
+          $files = @()
+          $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
+          $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
+          $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
+
+          foreach ($file in $files | Select-Object -Unique) {
+            $sig = Get-AuthenticodeSignature $file
+            if ($sig.Status -ne "Valid") {
+              throw "Invalid signature for ${file}: $($sig.Status)"
+            }
+          }
+
       - uses: actions/upload-artifact@v4
         with:
           name: opencode-electron-${{ matrix.settings.target }}
@@ -373,6 +544,7 @@ jobs:
     needs:
       - version
       - build-cli
+      - sign-cli-windows
       - build-tauri
       - build-electron
     runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -411,6 +583,16 @@ jobs:
           name: opencode-cli
           path: packages/opencode/dist
 
+      - uses: actions/download-artifact@v4
+        with:
+          name: opencode-cli-windows
+          path: packages/opencode/dist
+
+      - uses: actions/download-artifact@v4
+        with:
+          name: opencode-cli-signed-windows
+          path: packages/opencode/dist
+
       - uses: actions/download-artifact@v4
         if: needs.version.outputs.release
         with:

+ 0 - 54
.github/workflows/sign-cli.yml

@@ -1,54 +0,0 @@
-name: sign-cli
-
-on:
-  push:
-    branches:
-      - brendan/desktop-signpath
-  workflow_dispatch:
-
-permissions:
-  contents: read
-  actions: read
-
-jobs:
-  sign-cli:
-    runs-on: blacksmith-4vcpu-ubuntu-2404
-    if: github.repository == 'anomalyco/opencode'
-    steps:
-      - uses: actions/checkout@v3
-        with:
-          fetch-tags: true
-
-      - uses: ./.github/actions/setup-bun
-
-      - name: Build
-        run: |
-          ./packages/opencode/script/build.ts
-
-      - name: Upload unsigned Windows CLI
-        id: upload_unsigned_windows_cli
-        uses: actions/upload-artifact@v4
-        with:
-          name: unsigned-opencode-windows-cli
-          path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
-          if-no-files-found: error
-
-      - name: Submit SignPath signing request
-        id: submit_signpath_signing_request
-        uses: signpath/github-action-submit-signing-request@v1
-        with:
-          api-token: ${{ secrets.SIGNPATH_API_KEY }}
-          organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
-          project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
-          signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
-          artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
-          github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
-          wait-for-completion: true
-          output-artifact-directory: signed-opencode-cli
-
-      - name: Upload signed Windows CLI
-        uses: actions/upload-artifact@v4
-        with:
-          name: signed-opencode-windows-cli
-          path: signed-opencode-cli/*.exe
-          if-no-files-found: error

+ 3 - 0
.github/workflows/test.yml

@@ -100,6 +100,9 @@ jobs:
         run: bun --cwd packages/app test:e2e:local
         env:
           CI: true
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+          OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
+          OPENCODE_E2E_REQUIRE_PAID: "true"
         timeout-minutes: 30
 
       - name: Upload Playwright artifacts

+ 1 - 0
.gitignore

@@ -25,6 +25,7 @@ target
 
 # Local dev files
 opencode-dev
+UPCOMING_CHANGELOG.md
 logs/
 *.bun-build
 tsconfig.tsbuildinfo

+ 0 - 34
.opencode/agent/docs.md

@@ -1,34 +0,0 @@
----
-description: ALWAYS use this when writing docs
-color: "#38A3EE"
----
-
-You are an expert technical documentation writer
-
-You are not verbose
-
-Use a relaxed and friendly tone
-
-The title of the page should be a word or a 2-3 word phrase
-
-The description should be one short line, should not start with "The", should
-avoid repeating the title of the page, should be 5-10 words long
-
-Chunks of text should not be more than 2 sentences long
-
-Each section is separated by a divider of 3 dashes
-
-The section titles are short with only the first letter of the word capitalized
-
-The section titles are in the imperative mood
-
-The section titles should not repeat the term used in the page title, for
-example, if the page title is "Models", avoid using a section title like "Add
-new models". This might be unavoidable in some cases, but try to avoid it.
-
-Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
-
-For JS or TS code snippets remove trailing semicolons and any trailing commas
-that might not be needed.
-
-If you are making a commit prefix the commit message with `docs:`

+ 43 - 20
.opencode/command/changelog.md

@@ -1,23 +1,46 @@
 ---
-model: opencode/kimi-k2.5
+model: opencode/gpt-5.4
 ---
 
-create UPCOMING_CHANGELOG.md
-
-it should have sections
-
-```
-## TUI
-
-## Desktop
-
-## Core
-
-## Misc
-```
-
-fetch the latest github release for this repository to determine the last release version.
-
-find each PR that was merged since the last release
-
-for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
+Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
+If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
+Do not preserve, merge, or reuse text from the existing file.
+
+The input already contains the exact commit range since the last non-draft release.
+The commits are already filtered to the release-relevant packages and grouped into
+the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
+The input may also include a `## Community Contributors Input` section.
+
+Before writing any entry you keep, inspect the real diff with
+`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
+understand the actual code changes and not just the commit message (they may be misleading).
+Do not use `git log` or author metadata when deciding attribution.
+
+Rules:
+
+- Write the final file with sections in this order:
+  `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
+- Only include sections that have at least one notable entry
+- Keep one bullet per commit you keep
+- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
+- Start each bullet with a capital letter
+- Prefer what changed for users over what code changed internally
+- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
+- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
+- If an input bullet has no `(@username)` suffix, do not add one
+- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
+- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
+- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
+- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
+- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
+- Do not derive the thank-you section from the main summary bullets
+- Do not include the heading `## Community Contributors Input` in the final file
+- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
+
+**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
+
+<changelog_input>
+
+!`bun script/raw-changelog.ts $ARGUMENTS`
+
+</changelog_input>

+ 47 - 8
.opencode/plugins/tui-smoke.tsx

@@ -1,7 +1,14 @@
 /** @jsxImportSource @opentui/solid */
-import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { RGBA, VignetteEffect } from "@opentui/core"
-import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
+import type {
+  TuiKeybindSet,
+  TuiPlugin,
+  TuiPluginApi,
+  TuiPluginMeta,
+  TuiPluginModule,
+  TuiSlotPlugin,
+} from "@opencode-ai/plugin/tui"
 
 const tabs = ["overview", "counter", "help"]
 const bind = {
@@ -608,7 +615,7 @@ const Modal = (props: {
   )
 }
 
-const home = (input: Cfg): TuiSlotPlugin => ({
+const home = (api: TuiPluginApi, input: Cfg) => ({
   slots: {
     home_logo(ctx) {
       const map = ctx.theme.current
@@ -642,6 +649,36 @@ const home = (input: Cfg): TuiSlotPlugin => ({
         </box>
       )
     },
+    home_prompt(ctx, value) {
+      const skin = look(ctx.theme.current)
+      type Prompt = (props: {
+        workspaceID?: string
+        hint?: JSX.Element
+        placeholders?: {
+          normal?: string[]
+          shell?: string[]
+        }
+      }) => JSX.Element
+      if (!("Prompt" in api.ui)) return null
+      const view = api.ui.Prompt
+      if (typeof view !== "function") return null
+      const Prompt = view as Prompt
+      const normal = [
+        `[SMOKE] route check for ${input.label}`,
+        "[SMOKE] confirm home_prompt slot override",
+        "[SMOKE] verify api.ui.Prompt rendering",
+      ]
+      const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
+      const Hint = (
+        <box flexShrink={0} flexDirection="row" gap={1}>
+          <text fg={skin.muted}>
+            <span style={{ fg: skin.accent }}>•</span> smoke home prompt
+          </text>
+        </box>
+      )
+
+      return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
+    },
     home_bottom(ctx) {
       const skin = look(ctx.theme.current)
       const text = "extra content in the unified home bottom slot"
@@ -699,8 +736,8 @@ const block = (input: Cfg, order: number, title: string, text: string): TuiSlotP
   },
 })
 
-const slot = (input: Cfg): TuiSlotPlugin[] => [
-  home(input),
+const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
+  home(api, input),
   block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
   block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
   block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
@@ -813,7 +850,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
   ])
 }
 
-const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
+const tui: TuiPlugin = async (api, options, meta) => {
   if (options?.enabled === false) return
 
   await api.theme.install("./smoke-theme.json")
@@ -841,12 +878,14 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
   ])
 
   reg(api, value, keys)
-  for (const item of slot(value)) {
+  for (const item of slot(api, value)) {
     api.slots.register(item)
   }
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id: "tui-smoke",
   tui,
 }
+
+export default plugin

+ 0 - 1
.opencode/tui.json

@@ -1,6 +1,5 @@
 {
   "$schema": "https://opencode.ai/tui.json",
-  "theme": "smoke-theme",
   "plugin": [
     [
       "./plugins/tui-smoke.tsx",

+ 0 - 5
.signpath/policies/opencode/test-signing.yml

@@ -1,5 +0,0 @@
-github-policies:
-  runners:
-    allowed_groups:
-      - "GitHub Actions"
-      - "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"

+ 105 - 221
bun.lock

@@ -26,7 +26,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -79,7 +79,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -113,7 +113,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -140,11 +140,11 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
-        "@ai-sdk/anthropic": "2.0.0",
-        "@ai-sdk/openai": "2.0.2",
-        "@ai-sdk/openai-compatible": "1.0.1",
+        "@ai-sdk/anthropic": "3.0.64",
+        "@ai-sdk/openai": "3.0.48",
+        "@ai-sdk/openai-compatible": "2.0.37",
         "@hono/zod-validator": "catalog:",
         "@openauthjs/openauth": "0.0.0-20250322224806",
         "@opencode-ai/console-core": "workspace:*",
@@ -164,7 +164,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -188,7 +188,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -221,7 +221,7 @@
     },
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -252,7 +252,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -281,7 +281,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -297,7 +297,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -305,25 +305,25 @@
         "@actions/core": "1.11.1",
         "@actions/github": "6.0.1",
         "@agentclientprotocol/sdk": "0.14.1",
-        "@ai-sdk/amazon-bedrock": "3.0.82",
-        "@ai-sdk/anthropic": "2.0.65",
-        "@ai-sdk/azure": "2.0.91",
-        "@ai-sdk/cerebras": "1.0.36",
-        "@ai-sdk/cohere": "2.0.22",
-        "@ai-sdk/deepinfra": "1.0.36",
-        "@ai-sdk/gateway": "2.0.30",
-        "@ai-sdk/google": "2.0.54",
-        "@ai-sdk/google-vertex": "3.0.106",
-        "@ai-sdk/groq": "2.0.34",
-        "@ai-sdk/mistral": "2.0.27",
-        "@ai-sdk/openai": "2.0.89",
-        "@ai-sdk/openai-compatible": "1.0.32",
-        "@ai-sdk/perplexity": "2.0.23",
-        "@ai-sdk/provider": "2.0.1",
-        "@ai-sdk/provider-utils": "3.0.21",
-        "@ai-sdk/togetherai": "1.0.34",
-        "@ai-sdk/vercel": "1.0.33",
-        "@ai-sdk/xai": "2.0.51",
+        "@ai-sdk/amazon-bedrock": "4.0.83",
+        "@ai-sdk/anthropic": "3.0.64",
+        "@ai-sdk/azure": "3.0.49",
+        "@ai-sdk/cerebras": "2.0.41",
+        "@ai-sdk/cohere": "3.0.27",
+        "@ai-sdk/deepinfra": "2.0.41",
+        "@ai-sdk/gateway": "3.0.80",
+        "@ai-sdk/google": "3.0.53",
+        "@ai-sdk/google-vertex": "4.0.95",
+        "@ai-sdk/groq": "3.0.31",
+        "@ai-sdk/mistral": "3.0.27",
+        "@ai-sdk/openai": "3.0.48",
+        "@ai-sdk/openai-compatible": "2.0.37",
+        "@ai-sdk/perplexity": "3.0.26",
+        "@ai-sdk/provider": "3.0.8",
+        "@ai-sdk/provider-utils": "4.0.21",
+        "@ai-sdk/togetherai": "2.0.41",
+        "@ai-sdk/vercel": "2.0.39",
+        "@ai-sdk/xai": "3.0.75",
         "@aws-sdk/credential-providers": "3.993.0",
         "@clack/prompts": "1.0.0-alpha.1",
         "@effect/platform-node": "catalog:",
@@ -338,9 +338,9 @@
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
-        "@openrouter/ai-sdk-provider": "1.5.4",
-        "@opentui/core": "0.1.90",
-        "@opentui/solid": "0.1.90",
+        "@openrouter/ai-sdk-provider": "2.3.3",
+        "@opentui/core": "0.1.95",
+        "@opentui/solid": "0.1.95",
         "@parcel/watcher": "2.5.1",
         "@pierre/diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
@@ -348,7 +348,7 @@
         "@standard-schema/spec": "1.0.0",
         "@zip.js/zip.js": "2.7.62",
         "ai": "catalog:",
-        "ai-gateway-provider": "2.3.1",
+        "ai-gateway-provider": "3.1.2",
         "bonjour-service": "1.3.0",
         "bun-pty": "0.4.8",
         "chokidar": "4.0.3",
@@ -359,7 +359,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "5.3.3",
+        "gitlab-ai-provider": "6.0.0",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -370,7 +370,7 @@
         "mime-types": "3.0.2",
         "minimatch": "10.0.3",
         "open": "10.1.2",
-        "opencode-gitlab-auth": "2.0.0",
+        "opencode-gitlab-auth": "2.0.1",
         "opencode-poe-auth": "0.0.1",
         "opentui-spinner": "0.0.6",
         "partial-json": "0.1.7",
@@ -379,6 +379,7 @@
         "solid-js": "catalog:",
         "strip-ansi": "7.1.2",
         "tree-sitter-bash": "0.25.0",
+        "tree-sitter-powershell": "0.25.10",
         "turndown": "7.2.0",
         "ulid": "catalog:",
         "vscode-jsonrpc": "8.2.1",
@@ -424,22 +425,22 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
       },
       "devDependencies": {
-        "@opentui/core": "0.1.90",
-        "@opentui/solid": "0.1.90",
+        "@opentui/core": "0.1.95",
+        "@opentui/solid": "0.1.95",
         "@tsconfig/node22": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.1.90",
-        "@opentui/solid": ">=0.1.90",
+        "@opentui/core": ">=0.1.95",
+        "@opentui/solid": ">=0.1.95",
       },
       "optionalPeers": [
         "@opentui/core",
@@ -458,7 +459,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.90.10",
         "@tsconfig/node22": "catalog:",
@@ -469,7 +470,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -504,7 +505,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -551,7 +552,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "zod": "catalog:",
       },
@@ -562,7 +563,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.3.3",
+      "version": "1.3.13",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -595,16 +596,17 @@
     },
   },
   "trustedDependencies": [
-    "electron",
     "esbuild",
+    "tree-sitter-powershell",
+    "electron",
     "web-tree-sitter",
     "tree-sitter-bash",
   ],
   "patchedDependencies": {
-    "@openrouter/[email protected]": "patches/@openrouter%[email protected]",
     "[email protected]": "patches/[email protected]",
-    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
     "@standard-community/[email protected]": "patches/@standard-community%[email protected]",
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
   },
   "overrides": {
     "@types/bun": "catalog:",
@@ -612,7 +614,7 @@
   },
   "catalog": {
     "@cloudflare/workers-types": "4.20251008.0",
-    "@effect/platform-node": "4.0.0-beta.37",
+    "@effect/platform-node": "4.0.0-beta.42",
     "@hono/zod-validator": "0.4.2",
     "@kobalte/core": "0.13.11",
     "@octokit/rest": "22.0.0",
@@ -631,12 +633,12 @@
     "@types/node": "22.13.9",
     "@types/semver": "7.7.1",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
-    "ai": "5.0.124",
+    "ai": "6.0.138",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
     "drizzle-kit": "1.0.0-beta.19-d95b7a4",
     "drizzle-orm": "1.0.0-beta.19-d95b7a4",
-    "effect": "4.0.0-beta.37",
+    "effect": "4.0.0-beta.42",
     "fuzzysort": "3.1.0",
     "hono": "4.10.7",
     "hono-openapi": "1.1.2",
@@ -675,51 +677,51 @@
 
     "@agentclientprotocol/sdk": ["@agentclientprotocol/[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
 
-    "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.82", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yb1EkRCMWex0tnpHPLGQxoJEiJvMGOizuxzlXFOpuGFiYgE679NsWE/F8pHwtoAWsqLlylgGAJvJDIJ8us8LEw=="],
+    "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
 
-    "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
+    "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
 
-    "@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
+    "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
 
-    "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
+    "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
 
-    "@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
+    "@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
 
-    "@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-E+wzGPSa/XHmajO3WtX8mtq0ewy04tsHSpU6/SGwqbiykwWba/emi7ayZ4ir89s5OzbAen2g7T9zZiEchMfkHQ=="],
+    "@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-htT1Y7vBN0cRu/1pGnhx6DNH3xaNr0o0MjDkmii48X2+6S/WkOzVNtMjn7V3vLWEQIWNio5vw1hG/F43K8WLHA=="],
 
-    "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
+    "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
 
-    "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qvh2yxL5zJS9RO/Bf12pyYBIDmn+9GR1hT6e28IYWQWnt2Xq0h9XGps6XagLAv3VYYFg8c/ozkWVd4kXLZ25HA=="],
+    "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4vOEekW4TAYVHN0qgiwoUOQZhguGwZBiEw8LDeUmpWBm07QkLRAtxYCaSoMiA4hZZojao5mj6NRGEBW1CnDPtg=="],
 
-    "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ee2At5jgV+SqC6nrtPq20iH7N/aN+O36LrA4gkzVM4cmhM7bvQKVkOXhC1XxG+wsYG6UZi3Nekoi8MEjNWuRrw=="],
+    "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-K+1YprVMO8R6vTcNhqTqUWhOzX5V/hEY0pFx9KQL0/+MJjOgRi6DcOLoNBd7ONcjxYTyiFLRfk/0a/pHTtSgFA=="],
 
-    "@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.35", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.34", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-inUq29XvSVDer6JIeOkwAmCFxOtHPU0OZEhwaWoe3PI59naHIW4RIFA9wppLLV5fJI9WQcAfDKy0ZHW9nV3UJw=="],
+    "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.40", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ARjygiBQtVSgNBp3Sag+Bkwn68ub+cZPC05UpRGG+VY8/Q896K2yU1j4I0+S1eU0BQW/9DKbRG04d9Ayi2DUmA=="],
 
-    "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
+    "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="],
 
-    "@ai-sdk/google": ["@ai-sdk/google@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VKguP0x/PUYpdQyuA/uy5pDGJy6reL0X/yDKxHfL207aCUXpFIBmyMhVs4US39dkEVhtmIFSwXauY0Pt170JRw=="],
+    "@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
 
-    "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.106", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/google": "2.0.54", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f9sA66bmhgJoTwa+pHWFSdYxPa0lgdQ/MgYNxZptzVyGptoziTf1a9EIXEL3jiCD0qIBAg+IhDAaYalbvZaDqQ=="],
+    "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/google": "3.0.53", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xL44fHlTtDM7RLkMTgyqMfkfthA38JS91bbMaHItObIhte1PAIY936ZV1PLl/Z9A/oBAXjHWbXo5xDoHzB7LEg=="],
 
-    "@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
+    "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
 
-    "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="],
+    "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
 
-    "@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
+    "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
 
-    "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
+    "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
 
-    "@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aiaRvnc6mhQZKhTTSXPCjPH8Iqr5D/PfCN1hgVP/3RGTBbJtsd9HemIBSABeSdAKbsMH/PwJxgnqH75HEamcBA=="],
+    "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
 
-    "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
+    "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
 
-    "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
+    "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
-    "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
+    "@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
 
-    "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
+    "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
 
-    "@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
+    "@ai-sdk/xai": ["@ai-sdk/xai@3.0.75", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V8UKK4fNpI9cnrtsZBvUp9O9J6Y9fTKBRoSLyEaNGPirACewixmLDbXsSgAeownPVWiWpK34bFysd+XouI5Ywg=="],
 
     "@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
 
@@ -995,9 +997,9 @@
 
     "@effect/language-service": ["@effect/[email protected]", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
 
-    "@effect/platform-node": ["@effect/[email protected].37", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.37", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.37", "ioredis": "^5.7.0" } }, "sha512-dCfTNYGAT+1K+nu/0jw3FL/0DJXcobZCJs9SD5XJbj1DewWPhR9/AptP6zLGj8vdP8hXem6Aa53nze3HSujW3w=="],
+    "@effect/platform-node": ["@effect/[email protected].42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="],
 
-    "@effect/platform-node-shared": ["@effect/[email protected]0", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.40" } }, "sha512-WMRVG7T8ZDALKCOacsx2ZZj3Ccaoq8YGeD9q7ZL4q8RwQv8Nmrl+4+KZl95/zHCqXzgK9oUJOlBfQ7CZr6PQOQ=="],
+    "@effect/platform-node-shared": ["@effect/[email protected]2", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="],
 
     "@electron/asar": ["@electron/[email protected]", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
 
@@ -1487,27 +1489,25 @@
 
     "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
 
-    "@openrouter/ai-sdk-provider": ["@openrouter/[email protected]", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
-
-    "@openrouter/sdk": ["@openrouter/[email protected]", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
+    "@openrouter/ai-sdk-provider": ["@openrouter/[email protected]", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
 
     "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
 
-    "@opentui/core": ["@opentui/[email protected]0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
+    "@opentui/core": ["@opentui/[email protected]5", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/[email protected]0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
+    "@opentui/core-darwin-arm64": ["@opentui/[email protected]5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-92joqr0ucGaIBCl9uYhe5DwAPbgGMTaCsCeY8Yf3VQ72wjGbOTwnC1TvU5wC6bUmiyqfijCqMyuUnj83teIVVQ=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/[email protected]0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
+    "@opentui/core-darwin-x64": ["@opentui/[email protected]5", "", { "os": "darwin", "cpu": "x64" }, "sha512-+TLL3Kp3x7DTWEAkCAYe+RjRhl58QndoeXMstZNS8GQyrjSpUuivzwidzAz0HZK9SbZJfvaxZmXsToAIdI2fag=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/[email protected]0", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
+    "@opentui/core-linux-arm64": ["@opentui/[email protected]5", "", { "os": "linux", "cpu": "arm64" }, "sha512-dAYeRqh7P8o0xFZleDDR1Abt4gSvCISqw6syOrbH3dl7pMbVdGgzA5stM9jqMgdPUVE7Ngumo17C23ehkGv93A=="],
 
-    "@opentui/core-linux-x64": ["@opentui/[email protected]0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
+    "@opentui/core-linux-x64": ["@opentui/[email protected]5", "", { "os": "linux", "cpu": "x64" }, "sha512-O54TCgK8E7j2NKrDXUOTZqO4sb8JjeAfnhrStxAMMEw4RFCGWx3p3wLesqR16uKfFFJFDyoh2OWZ698tO88EAA=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/[email protected]0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
+    "@opentui/core-win32-arm64": ["@opentui/[email protected]5", "", { "os": "win32", "cpu": "arm64" }, "sha512-T1RlZ6U/95eYDN6rUm4SLOVA5LBR7iL3TcBroQhV/883bVczXIBPhriEXQayup5FsAemnQba1BzMNvy6128SUw=="],
 
-    "@opentui/core-win32-x64": ["@opentui/[email protected]0", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
+    "@opentui/core-win32-x64": ["@opentui/[email protected]5", "", { "os": "win32", "cpu": "x64" }, "sha512-lH2FHO0HSP2xWT+ccoz0BkLYFsMm7e6OYOh63BUHHh5b7ispnzP4aTyxiaLWrfJwdL0M9rp5cLIY32bhBKF2oA=="],
 
-    "@opentui/solid": ["@opentui/[email protected]0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
+    "@opentui/solid": ["@opentui/[email protected]5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.95", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-iotYCvULgDurLXv3vgOzTLnEOySHFOa/6cEDex76jBt+gkniOEh2cjxxIVt6lkfTsk6UNTk6yCdwNK3nca/j+Q=="],
 
     "@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 
@@ -2335,9 +2335,9 @@
 
     "agentkeepalive": ["[email protected]", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
 
-    "ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
+    "ai": ["ai@6.0.138", "", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="],
 
-    "ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
+    "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
 
     "ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
 
@@ -2839,7 +2839,7 @@
 
     "ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
 
-    "effect": ["[email protected].37", "", { "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-AVMXXtb6n62W4uvo1EvT7FJ41HfDvQRX8IY2FGPvfP361dtBArKK2JtE5vmFXTsxkW90WUdvJZYpVATGIzr/BA=="],
+    "effect": ["[email protected].42", "", { "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-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="],
 
     "ejs": ["[email protected]", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
 
@@ -3117,7 +3117,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
+    "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=="],
 
     "glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
@@ -3897,7 +3897,7 @@
 
     "opencode": ["opencode@workspace:packages/opencode"],
 
-    "opencode-gitlab-auth": ["[email protected].0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
+    "opencode-gitlab-auth": ["[email protected].1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="],
 
     "opencode-poe-auth": ["[email protected]", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
 
@@ -4601,7 +4601,7 @@
 
     "tree-sitter-bash": ["[email protected]", "", { "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=="],
 
-    "treeverse": ["[email protected]", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
+    "tree-sitter-powershell": ["[email protected]", "", { "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=="],
 
     "trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
 
@@ -4923,63 +4923,21 @@
 
     "@actions/http-client/undici": ["[email protected]", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
 
-    "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
-
-    "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
-
-    "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
-
-    "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
-
-    "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
-
-    "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
-
-    "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
-
-    "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
-
-    "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
-
-    "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="],
-
-    "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
+    "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w=="],
 
-    "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+    "@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
 
-    "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
+    "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
 
-    "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+    "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
 
-    "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+    "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
 
-    "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
+    "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
 
-    "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
+    "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
 
-    "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
-
-    "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
-
-    "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
-
-    "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
-
-    "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
-
-    "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+    "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
     "@astrojs/check/yargs": ["[email protected]", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
 
@@ -5463,15 +5421,7 @@
 
     "accepts/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
 
-    "ai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
-
-    "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
-
-    "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
-
-    "ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="],
+    "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
 
     "ajv-keywords/ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
 
@@ -5685,12 +5635,6 @@
 
     "nypm/tinyexec": ["[email protected]", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
 
-    "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
-
-    "opencode/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
-
-    "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
-
     "opencode-gitlab-auth/open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
 
     "opencode-poe-auth/open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -5867,16 +5811,6 @@
 
     "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
 
-    "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/anthropic/@ai-sdk/provider-utils/zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
-
-    "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
     "@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
     "@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -5885,28 +5819,6 @@
 
     "@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
-    "@ai-sdk/gateway/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
-
-    "@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/openai/@ai-sdk/provider-utils/zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
-
-    "@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
     "@astrojs/check/yargs/cliui": ["[email protected]", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
 
     "@astrojs/check/yargs/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6347,20 +6259,6 @@
 
     "accepts/mime-types/mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
 
-    "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
-
-    "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
-
-    "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
-
-    "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
-
-    "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
-
-    "ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
     "ajv-keywords/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
 
     "ansi-align/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -6451,10 +6349,6 @@
 
     "opencode-poe-auth/open/wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
 
-    "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
-    "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
-
     "opencontrol/@modelcontextprotocol/sdk/express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
 
     "opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["[email protected]", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
@@ -6725,12 +6619,6 @@
 
     "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["[email protected]", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
 
-    "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
     "ansi-align/string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
 
     "app-builder-lib/@electron/get/fs-extra/universalify": ["[email protected]", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -6779,10 +6667,6 @@
 
     "js-beautify/glob/path-scurry/lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
 
-    "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
-    "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
     "opencontrol/@modelcontextprotocol/sdk/express/accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
 
     "opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-a2eTu0ISjqPuojkNPnPXzVb/PLlDvw/DXDvmxi9RD5k=",
-    "aarch64-linux": "sha256-yLaTXRzZ7M/6j2WDP+IL1YCY3+rYY4Qmq3xTDatNzD0=",
-    "aarch64-darwin": "sha256-uGSVe8S/QvnW+RCI/CxzrlfAAJ1YA+NrhzRE0GTcnvE=",
-    "x86_64-darwin": "sha256-tplWx2tLg6jWvOBmM41lODJV8pHpkAm4HKWRG7lpkcU="
+    "x86_64-linux": "sha256-C7y5FMI1pGEgMw/vcPoBhK9tw5uGg1bk0gPXPUUVhgU=",
+    "aarch64-linux": "sha256-cUlQ9jp4WIaJkd4GRoHMWc+REG/OnnGCmsQUNmvg4is=",
+    "aarch64-darwin": "sha256-3GXmqG7yihJ91wS/jlW19qxGI62b1bFJnpGB4LcMlpY=",
+    "x86_64-darwin": "sha256-cUF0TfYg2nXnU80kWFpr9kNHlu9txiatIgrHTltgx4g="
   }
 }

+ 2 - 1
nix/node_modules.nix

@@ -20,7 +20,7 @@ let
 in
 stdenvNoCC.mkDerivation {
   pname = "opencode-node_modules";
-  version = "${packageJson.version}-${rev}";
+  version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
 
   src = lib.fileset.toSource {
     root = ../.;
@@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
       --filter '!./' \
       --filter './packages/opencode' \
       --filter './packages/desktop' \
+      --filter './packages/app' \
       --frozen-lockfile \
       --ignore-scripts \
       --no-progress

+ 4 - 0
nix/opencode.nix

@@ -3,6 +3,7 @@
   stdenvNoCC,
   callPackage,
   bun,
+  nodejs,
   sysctl,
   makeBinaryWrapper,
   models-dev,
@@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
 
   nativeBuildInputs = [
     bun
+    nodejs # for patchShebangs node_modules
     installShellFiles
     makeBinaryWrapper
     models-dev
@@ -29,6 +31,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
     runHook preConfigure
 
     cp -R ${finalAttrs.node_modules}/. .
+    patchShebangs node_modules
+    patchShebangs packages/*/node_modules
 
     runHook postConfigure
   '';

+ 7 - 6
package.json

@@ -25,7 +25,7 @@
       "packages/slack"
     ],
     "catalog": {
-      "@effect/platform-node": "4.0.0-beta.37",
+      "@effect/platform-node": "4.0.0-beta.42",
       "@types/bun": "1.3.11",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
@@ -45,8 +45,8 @@
       "dompurify": "3.3.1",
       "drizzle-kit": "1.0.0-beta.19-d95b7a4",
       "drizzle-orm": "1.0.0-beta.19-d95b7a4",
-      "effect": "4.0.0-beta.37",
-      "ai": "5.0.124",
+      "effect": "4.0.0-beta.42",
+      "ai": "6.0.138",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",
       "fuzzysort": "3.1.0",
@@ -104,6 +104,7 @@
     "protobufjs",
     "tree-sitter",
     "tree-sitter-bash",
+    "tree-sitter-powershell",
     "web-tree-sitter",
     "electron"
   ],
@@ -113,8 +114,8 @@
   },
   "patchedDependencies": {
     "@standard-community/[email protected]": "patches/@standard-community%[email protected]",
-    "@openrouter/[email protected]": "patches/@openrouter%[email protected].patch",
-    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]1.patch",
-    "[email protected]": "patches/[email protected].patch"
+    "[email protected]": "patches/[email protected].patch",
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]1.patch",
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected].patch"
   }
 }

+ 75 - 6
packages/app/e2e/fixtures.ts

@@ -1,5 +1,8 @@
 import { test as base, expect, type Page } from "@playwright/test"
+import { ManagedRuntime } from "effect"
 import type { E2EWindow } from "../src/testing/terminal"
+import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
+import { TestLLMServer } from "../../opencode/test/lib/llm-server"
 import {
   healthPhase,
   cleanupSession,
@@ -13,9 +16,38 @@ import {
 } from "./actions"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
+type LLMFixture = {
+  url: string
+  push: (...input: (Item | Reply)[]) => Promise<void>
+  text: (value: string, opts?: { usage?: Usage }) => Promise<void>
+  tool: (name: string, input: unknown) => Promise<void>
+  toolHang: (name: string, input: unknown) => Promise<void>
+  reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
+  fail: (message?: unknown) => Promise<void>
+  error: (status: number, body: unknown) => Promise<void>
+  hang: () => Promise<void>
+  hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
+  hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
+  calls: () => Promise<number>
+  wait: (count: number) => Promise<void>
+  inputs: () => Promise<Record<string, unknown>[]>
+  pending: () => Promise<number>
+}
+
 export const settingsKey = "settings.v3"
 
+const seedModel = (() => {
+  const [providerID = "opencode", modelID = "big-pickle"] = (
+    process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
+  ).split("/")
+  return {
+    providerID: providerID || "opencode",
+    modelID: modelID || "big-pickle",
+  }
+})()
+
 type TestFixtures = {
+  llm: LLMFixture
   sdk: ReturnType<typeof createSdk>
   gotoSession: (sessionID?: string) => Promise<void>
   withProject: <T>(
@@ -26,7 +58,11 @@ type TestFixtures = {
       trackSession: (sessionID: string, directory?: string) => void
       trackDirectory: (directory: string) => void
     }) => Promise<T>,
-    options?: { extra?: string[] },
+    options?: {
+      extra?: string[]
+      model?: { providerID: string; modelID: string }
+      setup?: (directory: string) => Promise<void>
+    },
   ) => Promise<T>
 }
 
@@ -36,6 +72,31 @@ type WorkerFixtures = {
 }
 
 export const test = base.extend<TestFixtures, WorkerFixtures>({
+  llm: async ({}, use) => {
+    const rt = ManagedRuntime.make(TestLLMServer.layer)
+    try {
+      const svc = await rt.runPromise(TestLLMServer.asEffect())
+      await use({
+        url: svc.url,
+        push: (...input) => rt.runPromise(svc.push(...input)),
+        text: (value, opts) => rt.runPromise(svc.text(value, opts)),
+        tool: (name, input) => rt.runPromise(svc.tool(name, input)),
+        toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
+        reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
+        fail: (message) => rt.runPromise(svc.fail(message)),
+        error: (status, body) => rt.runPromise(svc.error(status, body)),
+        hang: () => rt.runPromise(svc.hang),
+        hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
+        hits: () => rt.runPromise(svc.hits),
+        calls: () => rt.runPromise(svc.calls),
+        wait: (count) => rt.runPromise(svc.wait(count)),
+        inputs: () => rt.runPromise(svc.inputs),
+        pending: () => rt.runPromise(svc.pending),
+      })
+    } finally {
+      await rt.dispose()
+    }
+  },
   page: async ({ page }, use) => {
     let boundary: string | undefined
     setHealthPhase(page, "test")
@@ -89,7 +150,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
       const root = await createTestProject()
       const sessions = new Map<string, string>()
       const dirs = new Set<string>()
-      await seedStorage(page, { directory: root, extra: options?.extra })
+      await options?.setup?.(root)
+      await seedStorage(page, { directory: root, extra: options?.extra, model: options?.model })
 
       const gotoSession = async (sessionID?: string) => {
         await page.goto(sessionPath(root, sessionID))
@@ -123,9 +185,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
   },
 })
 
-async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
+async function seedStorage(
+  page: Page,
+  input: {
+    directory: string
+    extra?: string[]
+    model?: { providerID: string; modelID: string }
+  },
+) {
   await seedProjects(page, input)
-  await page.addInitScript(() => {
+  await page.addInitScript((model: { providerID: string; modelID: string }) => {
     const win = window as E2EWindow
     win.__opencode_e2e = {
       ...win.__opencode_e2e,
@@ -143,12 +212,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
     localStorage.setItem(
       "opencode.global.dat:model",
       JSON.stringify({
-        recent: [{ providerID: "opencode", modelID: "big-pickle" }],
+        recent: [model],
         user: [],
         variant: {},
       }),
     )
-  })
+  }, input.model ?? seedModel)
 }
 
 export { expect }

+ 76 - 31
packages/app/e2e/prompt/prompt.spec.ts

@@ -1,8 +1,44 @@
+import fs from "node:fs/promises"
+import path from "node:path"
 import { test, expect } from "../fixtures"
 import { promptSelector } from "../selectors"
-import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
+import { sessionIDFromUrl } from "../actions"
+import { createSdk } from "../utils"
 
-test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
+async function config(dir: string, url: string) {
+  await fs.writeFile(
+    path.join(dir, "opencode.json"),
+    JSON.stringify({
+      $schema: "https://opencode.ai/config.json",
+      enabled_providers: ["e2e-llm"],
+      provider: {
+        "e2e-llm": {
+          name: "E2E LLM",
+          npm: "@ai-sdk/openai-compatible",
+          env: [],
+          models: {
+            "test-model": {
+              name: "Test Model",
+              tool_call: true,
+              limit: { context: 128000, output: 32000 },
+            },
+          },
+          options: {
+            apiKey: "test-key",
+            baseURL: url,
+          },
+        },
+      },
+      agent: {
+        build: {
+          model: "e2e-llm/test-model",
+        },
+      },
+    }),
+  )
+}
+
+test("can send a prompt and receive a reply", async ({ page, llm, withProject }) => {
   test.setTimeout(120_000)
 
   const pageErrors: string[] = []
@@ -11,42 +47,51 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
   }
   page.on("pageerror", onPageError)
 
-  await gotoSession()
-
-  const token = `E2E_OK_${Date.now()}`
+  try {
+    await withProject(
+      async (project) => {
+        const sdk = createSdk(project.directory)
+        const token = `E2E_OK_${Date.now()}`
 
-  const prompt = page.locator(promptSelector)
-  await prompt.click()
-  await page.keyboard.type(`Reply with exactly: ${token}`)
-  await page.keyboard.press("Enter")
+        await llm.text(token)
+        await project.gotoSession()
 
-  await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+        const prompt = page.locator(promptSelector)
+        await prompt.click()
+        await page.keyboard.type(`Reply with exactly: ${token}`)
+        await page.keyboard.press("Enter")
 
-  const sessionID = (() => {
-    const id = sessionIDFromUrl(page.url())
-    if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-    return id
-  })()
+        await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
 
-  try {
-    await expect
-      .poll(
-        async () => {
-          const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
-          return messages
-            .filter((m) => m.info.role === "assistant")
-            .flatMap((m) => m.parts)
-            .filter((p) => p.type === "text")
-            .map((p) => p.text)
-            .join("\n")
-        },
-        { timeout: 90_000 },
-      )
+        const sessionID = (() => {
+          const id = sessionIDFromUrl(page.url())
+          if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
+          return id
+        })()
+        project.trackSession(sessionID)
 
-      .toContain(token)
+        await expect
+          .poll(
+            async () => {
+              const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
+              return messages
+                .filter((m) => m.info.role === "assistant")
+                .flatMap((m) => m.parts)
+                .filter((p) => p.type === "text")
+                .map((p) => p.text)
+                .join("\n")
+            },
+            { timeout: 30_000 },
+          )
+          .toContain(token)
+      },
+      {
+        model: { providerID: "e2e-llm", modelID: "test-model" },
+        setup: (dir) => config(dir, llm.url),
+      },
+    )
   } finally {
     page.off("pageerror", onPageError)
-    await cleanupSession({ sdk, sessionID })
   }
 
   if (pageErrors.length > 0) {

+ 68 - 0
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -13,6 +13,7 @@ import {
   sessionComposerDockSelector,
   sessionTodoToggleButtonSelector,
 } from "../selectors"
+import { modKey } from "../utils"
 
 type Sdk = Parameters<typeof clearSessionDockSeed>[0]
 type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -310,6 +311,73 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
   })
 })
 
+test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionQuestion(sdk, {
+        sessionID: session.id,
+        questions: [
+          {
+            header: "Need input",
+            question: "Pick one option",
+            options: [
+              { label: "Continue", description: "Continue now" },
+              { label: "Stop", description: "Stop here" },
+            ],
+          },
+        ],
+      })
+
+      const dock = page.locator(questionDockSelector)
+      const first = dock.locator('[data-slot="question-option"]').first()
+      const second = dock.locator('[data-slot="question-option"]').nth(1)
+
+      await expectQuestionBlocked(page)
+      await expect(first).toBeFocused()
+
+      await page.keyboard.press("ArrowDown")
+      await expect(second).toBeFocused()
+
+      await page.keyboard.press("Space")
+      await page.keyboard.press(`${modKey}+Enter`)
+      await expectQuestionOpen(page)
+    })
+  })
+})
+
+test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock question escape", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionQuestion(sdk, {
+        sessionID: session.id,
+        questions: [
+          {
+            header: "Need input",
+            question: "Pick one option",
+            options: [
+              { label: "Continue", description: "Continue now" },
+              { label: "Stop", description: "Stop here" },
+            ],
+          },
+        ],
+      })
+
+      const dock = page.locator(questionDockSelector)
+      const first = dock.locator('[data-slot="question-option"]').first()
+
+      await expectQuestionBlocked(page)
+      await expect(first).toBeFocused()
+
+      await page.keyboard.press("Escape")
+      await expectQuestionOpen(page)
+    })
+  })
+})
+
 test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
     await gotoSession(session.id)

+ 2 - 0
packages/app/e2e/session/session-review.spec.ts

@@ -234,6 +234,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
 }
 
 test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
+  test.skip(true, "Flaky in CI for now.")
   test.setTimeout(180_000)
 
   const tag = `review-comment-${Date.now()}`
@@ -283,6 +284,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
 })
 
 test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
+  test.skip(true, "Flaky in CI for now.")
   test.setTimeout(180_000)
 
   const tag = `review-file-comment-${Date.now()}`

+ 24 - 24
packages/app/e2e/settings/settings.spec.ts

@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
   const dialog = await openSettings(page)
   const input = dialog.locator(settingsCodeFontSelector)
   await expect(input).toBeVisible()
-  await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
+  await expect(input).toHaveAttribute("placeholder", "System Mono")
 
   const initialFontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
   const initialUIFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
   )
-  expect(initialFontFamily).toContain("IBM Plex Mono")
+  expect(initialFontFamily).toContain("ui-monospace")
 
   const next = "Test Mono"
 
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
     })
     .toMatchObject({
       appearance: {
-        font: next,
+        mono: next,
       },
     })
 
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
   const dialog = await openSettings(page)
   const input = dialog.locator(settingsUIFontSelector)
   await expect(input).toBeVisible()
-  await expect(input).toHaveAttribute("placeholder", "Inter")
+  await expect(input).toHaveAttribute("placeholder", "System Sans")
 
   const initialFontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
   const initialCodeFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
   )
-  expect(initialFontFamily).toContain("Inter")
+  expect(initialFontFamily).toContain("ui-sans-serif")
 
   const next = "Test Sans"
 
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
     })
     .toMatchObject({
       appearance: {
-        uiFont: next,
+        sans: next,
       },
     })
 
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
     })
     .toMatchObject({
       appearance: {
-        font: "Reset Mono",
+        mono: "Reset Mono",
       },
     })
 
   await input.clear()
   await input.press("Space")
   await expect(input).toHaveValue("")
-  await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
+  await expect(input).toHaveAttribute("placeholder", "System Mono")
 
   await expect
     .poll(async () => {
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
     })
     .toMatchObject({
       appearance: {
-        font: "",
+        mono: "",
       },
     })
 
   const fontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
   )
-  expect(fontFamily).toContain("IBM Plex Mono")
+  expect(fontFamily).toContain("ui-monospace")
   expect(fontFamily).not.toContain("Reset Mono")
 })
 
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
     })
     .toMatchObject({
       appearance: {
-        uiFont: "Reset Sans",
+        sans: "Reset Sans",
       },
     })
 
   await input.clear()
   await input.press("Space")
   await expect(input).toHaveValue("")
-  await expect(input).toHaveAttribute("placeholder", "Inter")
+  await expect(input).toHaveAttribute("placeholder", "System Sans")
 
   await expect
     .poll(async () => {
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
     })
     .toMatchObject({
       appearance: {
-        uiFont: "",
+        sans: "",
       },
     })
 
   const fontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
   )
-  expect(fontFamily).toContain("Inter")
+  expect(fontFamily).toContain("ui-sans-serif")
   expect(fontFamily).not.toContain("Reset Sans")
 })
 
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
     return raw ? JSON.parse(raw) : null
   }, settingsKey)
 
-  const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
-  const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
+  const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
+  const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
 
   await code.click()
   await code.clear()
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
     })
     .toMatchObject({
       appearance: {
-        font: mono,
-        uiFont: sans,
+        mono,
+        sans,
       },
     })
 
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
   expect(updatedMono).not.toBe(initialMono)
   expect(updatedSans).toContain(sans)
   expect(updatedSans).not.toBe(initialSans)
-  expect(updatedSettings?.appearance?.font).toBe(mono)
-  expect(updatedSettings?.appearance?.uiFont).toBe(sans)
+  expect(updatedSettings?.appearance?.mono).toBe(mono)
+  expect(updatedSettings?.appearance?.sans).toBe(sans)
 
   await closeDialog(page, dialog)
   await page.reload()
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
     })
     .toMatchObject({
       appearance: {
-        font: mono,
-        uiFont: sans,
+        mono,
+        sans,
       },
     })
 
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
   expect(rehydratedMono).not.toBe(initialMono)
   expect(rehydratedSans).toContain(sans)
   expect(rehydratedSans).not.toBe(initialSans)
-  expect(rehydratedSettings?.appearance?.font).toBe(mono)
-  expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
+  expect(rehydratedSettings?.appearance?.mono).toBe(mono)
+  expect(rehydratedSettings?.appearance?.sans).toBe(sans)
 })
 
 test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

+ 1 - 1
packages/app/index.html

@@ -2,7 +2,7 @@
 <html lang="en" style="background-color: var(--background-base)">
   <head>
     <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
     <title>OpenCode</title>
     <link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
     <link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "description": "",
   "type": "module",
   "exports": {

+ 1 - 1
packages/app/script/e2e-local.ts

@@ -71,7 +71,7 @@ const serverEnv = {
   OPENCODE_E2E_PROJECT_DIR: repoDir,
   OPENCODE_E2E_SESSION_TITLE: "E2E Session",
   OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
-  OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
+  OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
   OPENCODE_CLIENT: "app",
   OPENCODE_STRICT_CONFIG_DEPS: "true",
 } satisfies Record<string, string>

+ 12 - 3
packages/app/src/app.tsx

@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
 
 const HomeRoute = lazy(() => import("@/pages/home"))
-const Session = lazy(() => import("@/pages/session"))
+const loadSession = () => import("@/pages/session")
+const Session = lazy(loadSession)
 const Loading = () => <div class="size-full" />
 
+if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
+  void loadSession()
+}
+
 const SessionRoute = () => (
   <SessionProviders>
     <Session />
@@ -178,7 +183,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
           }
         }).pipe(
           effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
-          Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
+          Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
           Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
           Effect.runPromise,
         ),
@@ -278,7 +283,11 @@ export function AppInterface(props: {
   disableHealthCheck?: boolean
 }) {
   return (
-    <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
+    <ServerProvider
+      defaultServer={props.defaultServer}
+      disableHealthCheck={props.disableHealthCheck}
+      servers={props.servers}
+    >
       <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
         <ServerKey>
           <GlobalSDKProvider>

+ 6 - 2
packages/app/src/components/prompt-input.tsx

@@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!cmd) return
     promptProbe.select(cmd.id)
     closePopover()
+    const images = imageAttachments()
 
     if (cmd.type === "custom") {
       const text = `/${cmd.trigger} `
       setEditorText(text)
-      prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+      prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
       focusEditorEnd()
       return
     }
 
     clearEditor()
-    prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    prompt.set([...DEFAULT_PROMPT, ...images], 0)
     command.trigger(cmd.id, "slash")
   }
 
@@ -1343,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               autocapitalize={store.mode === "normal" ? "sentences" : "off"}
               autocorrect={store.mode === "normal" ? "on" : "off"}
               spellcheck={store.mode === "normal"}
+              inputMode="text"
+              // @ts-expect-error
+              autocomplete="off"
               onInput={handleInput}
               onPaste={handlePaste}
               onCompositionStart={handleCompositionStart}

+ 24 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
     expect(synthetic).toHaveLength(1)
   })
 
+  test("adds file parts for @mentions inside comment text", () => {
+    const result = buildRequestParts({
+      prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
+      context: [
+        {
+          key: "ctx:comment-mention",
+          type: "file",
+          path: "src/review.ts",
+          comment: "Compare with @src/shared.ts and @src/review.ts.",
+        },
+      ],
+      images: [],
+      text: "look",
+      messageID: "msg_comment_mentions",
+      sessionID: "ses_comment_mentions",
+      sessionDirectory: "/repo",
+    })
+
+    const files = result.requestParts.filter((part) => part.type === "file")
+    expect(files).toHaveLength(2)
+    expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
+    expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
+  })
+
   test("handles Windows paths correctly (simulated on macOS)", () => {
     const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
 

+ 26 - 0
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
 const fileQuery = (selection: FileSelection | undefined) =>
   selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
 
+const mention = /(^|[\s([{"'])@(\S+)/g
+
+const parseCommentMentions = (comment: string) => {
+  return Array.from(comment.matchAll(mention)).flatMap((match) => {
+    const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
+    if (!path) return []
+    return [path]
+  })
+}
+
 const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
 const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
 
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
 
     if (!comment) return [filePart]
 
+    const mentions = parseCommentMentions(comment).flatMap((path) => {
+      const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
+      if (used.has(url)) return []
+      used.add(url)
+      return [
+        {
+          id: Identifier.ascending("part"),
+          type: "file",
+          mime: "text/plain",
+          url,
+          filename: getFilename(path),
+        } satisfies PromptRequestPart,
+      ]
+    })
+
     return [
       {
         id: Identifier.ascending("part"),
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
         }),
       } satisfies PromptRequestPart,
       filePart,
+      ...mentions,
     ]
   })
 

+ 82 - 60
packages/app/src/context/global-sdk.tsx

@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     const aborted = (error: unknown) => abortError.safeParse(error).success
 
     let attempt: AbortController | undefined
+    let run: Promise<void> | undefined
+    let started = false
     const HEARTBEAT_TIMEOUT_MS = 15_000
     let lastEventAt = Date.now()
     let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       heartbeat = undefined
     }
 
-    void (async () => {
-      while (!abort.signal.aborted) {
-        attempt = new AbortController()
-        lastEventAt = Date.now()
-        const onAbort = () => {
-          attempt?.abort()
-        }
-        abort.signal.addEventListener("abort", onAbort)
-        try {
-          const events = await eventSdk.global.event({
-            signal: attempt.signal,
-            onSseError: (error) => {
-              if (aborted(error)) return
-              if (streamErrorLogged) return
+    const start = () => {
+      if (started) return run
+      started = true
+      run = (async () => {
+        while (!abort.signal.aborted && started) {
+          attempt = new AbortController()
+          lastEventAt = Date.now()
+          const onAbort = () => {
+            attempt?.abort()
+          }
+          abort.signal.addEventListener("abort", onAbort)
+          try {
+            const events = await eventSdk.global.event({
+              signal: attempt.signal,
+              onSseError: (error) => {
+                if (aborted(error)) return
+                if (streamErrorLogged) return
+                streamErrorLogged = true
+                console.error("[global-sdk] event stream error", {
+                  url: currentServer.http.url,
+                  fetch: eventFetch ? "platform" : "webview",
+                  error,
+                })
+              },
+            })
+            let yielded = Date.now()
+            resetHeartbeat()
+            for await (const event of events.stream) {
+              resetHeartbeat()
+              streamErrorLogged = false
+              const directory = event.directory ?? "global"
+              const payload = event.payload
+              const k = key(directory, payload)
+              if (k) {
+                const i = coalesced.get(k)
+                if (i !== undefined) {
+                  queue[i] = { directory, payload }
+                  if (payload.type === "message.part.updated") {
+                    const part = payload.properties.part
+                    staleDeltas.add(deltaKey(directory, part.messageID, part.id))
+                  }
+                  continue
+                }
+                coalesced.set(k, queue.length)
+              }
+              queue.push({ directory, payload })
+              schedule()
+
+              if (Date.now() - yielded < STREAM_YIELD_MS) continue
+              yielded = Date.now()
+              await wait(0)
+            }
+          } catch (error) {
+            if (!aborted(error) && !streamErrorLogged) {
               streamErrorLogged = true
-              console.error("[global-sdk] event stream error", {
+              console.error("[global-sdk] event stream failed", {
                 url: currentServer.http.url,
                 fetch: eventFetch ? "platform" : "webview",
                 error,
               })
-            },
-          })
-          let yielded = Date.now()
-          resetHeartbeat()
-          for await (const event of events.stream) {
-            resetHeartbeat()
-            streamErrorLogged = false
-            const directory = event.directory ?? "global"
-            const payload = event.payload
-            const k = key(directory, payload)
-            if (k) {
-              const i = coalesced.get(k)
-              if (i !== undefined) {
-                queue[i] = { directory, payload }
-                if (payload.type === "message.part.updated") {
-                  const part = payload.properties.part
-                  staleDeltas.add(deltaKey(directory, part.messageID, part.id))
-                }
-                continue
-              }
-              coalesced.set(k, queue.length)
             }
-            queue.push({ directory, payload })
-            schedule()
-
-            if (Date.now() - yielded < STREAM_YIELD_MS) continue
-            yielded = Date.now()
-            await wait(0)
-          }
-        } catch (error) {
-          if (!aborted(error) && !streamErrorLogged) {
-            streamErrorLogged = true
-            console.error("[global-sdk] event stream failed", {
-              url: currentServer.http.url,
-              fetch: eventFetch ? "platform" : "webview",
-              error,
-            })
+          } finally {
+            abort.signal.removeEventListener("abort", onAbort)
+            attempt = undefined
+            clearHeartbeat()
           }
-        } finally {
-          abort.signal.removeEventListener("abort", onAbort)
-          attempt = undefined
-          clearHeartbeat()
+
+          if (abort.signal.aborted || !started) return
+          await wait(RECONNECT_DELAY_MS)
         }
+      })().finally(() => {
+        run = undefined
+        flush()
+      })
+      return run
+    }
 
-        if (abort.signal.aborted) return
-        await wait(RECONNECT_DELAY_MS)
-      }
-    })().finally(flush)
+    const stop = () => {
+      started = false
+      attempt?.abort()
+      clearHeartbeat()
+    }
 
     const onVisibility = () => {
       if (typeof document === "undefined") return
       if (document.visibilityState !== "visible") return
+      if (!started) return
       if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
       attempt?.abort()
     }
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       if (typeof document !== "undefined") {
         document.removeEventListener("visibilitychange", onVisibility)
       }
+      stop()
       abort.abort()
       flush()
     })
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     return {
       url: currentServer.http.url,
       client: sdk,
-      event: emitter,
+      event: {
+        on: emitter.on.bind(emitter),
+        listen: emitter.listen.bind(emitter),
+        start,
+      },
       createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
         const s = server.current
         if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))

+ 20 - 0
packages/app/src/context/global-sync.tsx

@@ -72,10 +72,16 @@ function createGlobalSync() {
   let projectWritten = false
   let bootedAt = 0
   let bootingRoot = false
+  let eventFrame: number | undefined
+  let eventTimer: ReturnType<typeof setTimeout> | undefined
 
   onCleanup(() => {
     active = false
   })
+  onCleanup(() => {
+    if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
+    if (eventTimer !== undefined) clearTimeout(eventTimer)
+  })
 
   const cacheProjects = () => {
     setProjectCache(
@@ -348,6 +354,20 @@ function createGlobalSync() {
   }
 
   onMount(() => {
+    if (typeof requestAnimationFrame === "function") {
+      eventFrame = requestAnimationFrame(() => {
+        eventFrame = undefined
+        eventTimer = setTimeout(() => {
+          eventTimer = undefined
+          globalSDK.event.start()
+        }, 0)
+      })
+    } else {
+      eventTimer = setTimeout(() => {
+        eventTimer = undefined
+        globalSDK.event.start()
+      }, 0)
+    }
     void bootstrap()
   })
 

+ 16 - 14
packages/app/src/context/global-sync/bootstrap.ts

@@ -43,8 +43,10 @@ function waitForPaint() {
     const timer = setTimeout(finish, 50)
     if (typeof requestAnimationFrame !== "function") return
     requestAnimationFrame(() => {
-      clearTimeout(timer)
-      finish()
+      setTimeout(() => {
+        clearTimeout(timer)
+        finish()
+      }, 0)
     })
   })
 }
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
   setGlobalStore: SetStoreFunction<GlobalStore>
 }) {
   const fast = [
-    () =>
-      retry(() =>
-        input.globalSDK.path.get().then((x) => {
-          input.setGlobalStore("path", x.data!)
-        }),
-      ),
     () =>
       retry(() =>
         input.globalSDK.global.config.get().then((x) => {
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
   ]
 
   const slow = [
+    () =>
+      retry(() =>
+        input.globalSDK.path.get().then((x) => {
+          input.setGlobalStore("path", x.data!)
+        }),
+      ),
     () =>
       retry(() =>
         input.globalSDK.project.list().then((x) => {
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
   if (loading) input.setStore("status", "partial")
 
   const fast = [
+    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
+    () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+    () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
+  ]
+
+  const slow = [
     () =>
       seededProject
         ? Promise.resolve()
         : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
-    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
-    () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
     () =>
       seededPath
         ? Promise.resolve()
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
               if (next) input.setStore("project", next)
             }),
           ),
-    () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
     () =>
       retry(() =>
         input.sdk.vcs.get().then((x) => {
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
           )
         }),
       ),
-  ]
-
-  const slow = [
     () => Promise.resolve(input.loadSessions(input.directory)),
     () =>
       retry(() =>

+ 31 - 16
packages/app/src/context/layout.tsx

@@ -13,7 +13,8 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 import { createPathHelpers } from "./file/path"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
-const DEFAULT_PANEL_WIDTH = 344
+const DEFAULT_SIDEBAR_WIDTH = 344
+const DEFAULT_FILE_TREE_WIDTH = 200
 const DEFAULT_SESSION_WIDTH = 600
 const DEFAULT_TERMINAL_HEIGHT = 280
 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -161,11 +162,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         if (!isRecord(fileTree)) return fileTree
         if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
 
-        const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
+        const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
         return {
           ...fileTree,
           opened: true,
-          width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
+          width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
           tab: "changes",
         }
       })()
@@ -230,7 +231,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       createStore({
         sidebar: {
           opened: false,
-          width: DEFAULT_PANEL_WIDTH,
+          width: DEFAULT_SIDEBAR_WIDTH,
           workspaces: {} as Record<string, boolean>,
           workspacesDefault: false,
         },
@@ -243,8 +244,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           panelOpened: true,
         },
         fileTree: {
-          opened: true,
-          width: DEFAULT_PANEL_WIDTH,
+          opened: false,
+          width: DEFAULT_FILE_TREE_WIDTH,
           tab: "changes" as "changes" | "all",
         },
         session: {
@@ -543,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       }
     })
 
+    let sessionFrame: number | undefined
+    let sessionTimer: number | undefined
+
     onMount(() => {
-      Promise.all(
-        server.projects.list().map((project) => {
-          return globalSync.project.loadSessions(project.worktree)
-        }),
-      )
+      sessionFrame = requestAnimationFrame(() => {
+        sessionFrame = undefined
+        sessionTimer = window.setTimeout(() => {
+          sessionTimer = undefined
+          void Promise.all(
+            server.projects.list().map((project) => {
+              return globalSync.project.loadSessions(project.worktree)
+            }),
+          )
+        }, 0)
+      })
+    })
+
+    onCleanup(() => {
+      if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
+      if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
     })
 
     return {
@@ -628,32 +643,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       fileTree: {
         opened: createMemo(() => store.fileTree?.opened ?? true),
-        width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
+        width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
         tab: createMemo(() => store.fileTree?.tab ?? "changes"),
         setTab(tab: "changes" | "all") {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
+            setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
             return
           }
           setStore("fileTree", "tab", tab)
         },
         open() {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
+            setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
             return
           }
           setStore("fileTree", "opened", true)
         },
         close() {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
+            setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
             return
           }
           setStore("fileTree", "opened", false)
         },
         toggle() {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
+            setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
             return
           }
           setStore("fileTree", "opened", (x) => !x)

+ 9 - 1
packages/app/src/context/server.tsx

@@ -94,7 +94,11 @@ export namespace ServerConnection {
 
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
-  init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
+  init: (props: {
+    defaultServer: ServerConnection.Key
+    disableHealthCheck?: boolean
+    servers?: Array<ServerConnection.Any>
+  }) => {
     const checkServerHealth = useCheckServerHealth()
 
     const [store, setStore, _, ready] = persisted(
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       const current_ = current()
       if (!current_) return
 
+      if (props.disableHealthCheck) {
+        setState("healthy", true)
+        return
+      }
       setState("healthy", undefined)
       onCleanup(startHealthPolling(current_))
     })

+ 20 - 22
packages/app/src/context/settings.tsx

@@ -32,8 +32,8 @@ export interface Settings {
   }
   appearance: {
     fontSize: number
-    font: string
-    uiFont: string
+    mono: string
+    sans: string
   }
   keybinds: Record<string, string>
   permissions: {
@@ -43,20 +43,18 @@ export interface Settings {
   sounds: SoundSettings
 }
 
-export const monoDefault = "IBM Plex Mono"
-export const sansDefault = "Inter"
+export const monoDefault = "System Mono"
+export const sansDefault = "System Sans"
 
 const monoFallback =
   'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
 const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
 
-const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
-const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
-const monoKey = "ibm-plex-mono"
+const monoBase = monoFallback
+const sansBase = sansFallback
 
-function input(font: string | undefined, key?: string) {
-  if (!font || font === key || !font.trim()) return ""
-  return font
+function input(font: string | undefined) {
+  return font ?? ""
 }
 
 function family(font: string) {
@@ -64,14 +62,14 @@ function family(font: string) {
   return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
 }
 
-function stack(font: string | undefined, base: string, key?: string) {
-  const value = input(font, key).trim()
+function stack(font: string | undefined, base: string) {
+  const value = font?.trim() ?? ""
   if (!value) return base
   return `${family(value)}, ${base}`
 }
 
 export function monoInput(font: string | undefined) {
-  return input(font, monoKey)
+  return input(font)
 }
 
 export function sansInput(font: string | undefined) {
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
 }
 
 export function monoFontFamily(font: string | undefined) {
-  return stack(font, monoBase, monoKey)
+  return stack(font, monoBase)
 }
 
 export function sansFontFamily(font: string | undefined) {
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
   },
   appearance: {
     fontSize: 14,
-    font: "",
-    uiFont: "",
+    mono: "",
+    sans: "",
   },
   keybinds: {},
   permissions: {
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
     createEffect(() => {
       if (typeof document === "undefined") return
       const root = document.documentElement
-      root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
-      root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
+      root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
+      root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
     })
 
     return {
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         setFontSize(value: number) {
           setStore("appearance", "fontSize", value)
         },
-        font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
+        font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
         setFont(value: string) {
-          setStore("appearance", "font", value.trim() ? value : "")
+          setStore("appearance", "mono", value.trim() ? value : "")
         },
-        uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
+        uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
         setUIFont(value: string) {
-          setStore("appearance", "uiFont", value.trim() ? value : "")
+          setStore("appearance", "sans", value.trim() ? value : "")
         },
       },
       keybinds: {

+ 51 - 2
packages/app/src/pages/session.tsx

@@ -544,6 +544,8 @@ export default function Page() {
   let reviewFrame: number | undefined
   let refreshFrame: number | undefined
   let refreshTimer: number | undefined
+  let todoFrame: number | undefined
+  let todoTimer: number | undefined
   let diffFrame: number | undefined
   let diffTimer: number | undefined
 
@@ -718,7 +720,6 @@ export default function Page() {
             if (!info) return true
             return Date.now() - info.at > SESSION_PREFETCH_TTL
           })()
-      const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
       untrack(() => {
         void sync.session.sync(id)
       })
@@ -730,13 +731,47 @@ export default function Page() {
           if (params.id !== id) return
           untrack(() => {
             if (stale) void sync.session.sync(id, { force: true })
-            void sync.session.todo(id, todos ? { force: true } : undefined)
           })
         }, 0)
       })
     }),
   )
 
+  createEffect(
+    on(
+      () => {
+        const id = params.id
+        return [
+          sdk.directory,
+          id,
+          id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
+          id ? composer.blocked() : false,
+        ] as const
+      },
+      ([dir, id, status, blocked]) => {
+        if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
+        if (todoTimer !== undefined) window.clearTimeout(todoTimer)
+        todoFrame = undefined
+        todoTimer = undefined
+        if (!id) return
+        if (status === "idle" && !blocked) return
+        const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
+
+        todoFrame = requestAnimationFrame(() => {
+          todoFrame = undefined
+          todoTimer = window.setTimeout(() => {
+            todoTimer = undefined
+            if (sdk.directory !== dir || params.id !== id) return
+            untrack(() => {
+              void sync.session.todo(id, cached ? { force: true } : undefined)
+            })
+          }, 0)
+        })
+      },
+      { defer: true },
+    ),
+  )
+
   createEffect(
     on(
       () => visibleUserMessages().at(-1)?.id,
@@ -1011,6 +1046,9 @@ export default function Page() {
         onLineCommentUpdate={updateCommentInContext}
         onLineCommentDelete={removeCommentFromContext}
         lineCommentActions={reviewCommentActions()}
+        commentMentions={{
+          items: file.searchFilesAndDirectories,
+        }}
         comments={comments.all()}
         focusedComment={comments.focus()}
         onFocusedCommentChange={comments.setFocus}
@@ -1640,6 +1678,15 @@ export default function Page() {
     consumePendingMessage: layout.pendingMessage.consume,
   })
 
+  createEffect(
+    on(
+      () => params.id,
+      (id) => {
+        if (!id) requestAnimationFrame(() => inputRef?.focus())
+      },
+    ),
+  )
+
   onMount(() => {
     document.addEventListener("keydown", handleKeyDown)
   })
@@ -1649,6 +1696,8 @@ export default function Page() {
     if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
     if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
     if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
+    if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
+    if (todoTimer !== undefined) window.clearTimeout(todoTimer)
     if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
     if (diffTimer !== undefined) window.clearTimeout(diffTimer)
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)

+ 202 - 82
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -11,6 +11,51 @@ import { useSDK } from "@/context/sdk"
 
 const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
 
+function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
+  return (
+    <span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
+      <span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
+        <Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
+          <Icon name="check-small" size="small" />
+        </Show>
+      </span>
+    </span>
+  )
+}
+
+function Option(props: {
+  multi: boolean
+  picked: boolean
+  label: string
+  description?: string
+  disabled: boolean
+  ref?: (el: HTMLButtonElement) => void
+  onFocus?: VoidFunction
+  onClick: VoidFunction
+}) {
+  return (
+    <button
+      type="button"
+      ref={props.ref}
+      data-slot="question-option"
+      data-picked={props.picked}
+      role={props.multi ? "checkbox" : "radio"}
+      aria-checked={props.picked}
+      disabled={props.disabled}
+      onFocus={props.onFocus}
+      onClick={props.onClick}
+    >
+      <Mark multi={props.multi} picked={props.picked} />
+      <span data-slot="question-option-main">
+        <span data-slot="option-label">{props.label}</span>
+        <Show when={props.description}>
+          <span data-slot="option-description">{props.description}</span>
+        </Show>
+      </span>
+    </button>
+  )
+}
+
 export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
   const sdk = useSDK()
   const language = useLanguage()
@@ -25,22 +70,30 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     custom: cached?.custom ?? ([] as string[]),
     customOn: cached?.customOn ?? ([] as boolean[]),
     editing: false,
+    focus: 0,
   })
 
   let root: HTMLDivElement | undefined
+  let customRef: HTMLButtonElement | undefined
+  let optsRef: HTMLButtonElement[] = []
   let replied = false
+  let focusFrame: number | undefined
 
   const question = createMemo(() => questions()[store.tab])
   const options = createMemo(() => question()?.options ?? [])
   const input = createMemo(() => store.custom[store.tab] ?? "")
   const on = createMemo(() => store.customOn[store.tab] === true)
   const multi = createMemo(() => question()?.multiple === true)
+  const count = createMemo(() => options().length + 1)
 
   const summary = createMemo(() => {
     const n = Math.min(store.tab + 1, total())
     return language.t("session.question.progress", { current: n, total: total() })
   })
 
+  const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
+  const customPlaceholder = () => language.t("ui.question.custom.placeholder")
+
   const last = createMemo(() => store.tab >= total() - 1)
 
   const customUpdate = (value: string, selected: boolean = on()) => {
@@ -85,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     root.style.setProperty("--question-prompt-max-height", `${max}px`)
   }
 
+  const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
+
+  const pickFocus = (tab: number = store.tab) => {
+    const list = questions()[tab]?.options ?? []
+    if (store.customOn[tab] === true) return list.length
+    return Math.max(
+      0,
+      list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
+    )
+  }
+
+  const focus = (i: number) => {
+    const next = clamp(i)
+    setStore("focus", next)
+    if (store.editing) return
+    if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
+    focusFrame = requestAnimationFrame(() => {
+      focusFrame = undefined
+      const el = next === options().length ? customRef : optsRef[next]
+      el?.focus()
+    })
+  }
+
   onMount(() => {
     let raf: number | undefined
     const update = () => {
@@ -109,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       observer.disconnect()
       if (raf !== undefined) cancelAnimationFrame(raf)
     })
+
+    focus(pickFocus())
   })
 
   onCleanup(() => {
+    if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
     if (replied) return
     cache.set(props.request.id, {
       tab: store.tab,
@@ -164,6 +243,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
 
   const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
 
+  const answered = (i: number) => {
+    if ((store.answers[i]?.length ?? 0) > 0) return true
+    return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
+  }
+
+  const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
+
   const pick = (answer: string, custom: boolean = false) => {
     setStore("answers", store.tab, [answer])
     if (custom) setStore("custom", store.tab, answer)
@@ -180,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
 
   const customToggle = () => {
     if (sending()) return
+    setStore("focus", options().length)
 
     if (!multi()) {
       setStore("customOn", store.tab, true)
@@ -199,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const value = input().trim()
     if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
     setStore("editing", false)
+    focus(options().length)
   }
 
   const customOpen = () => {
     if (sending()) return
+    setStore("focus", options().length)
     if (!on()) setStore("customOn", store.tab, true)
     setStore("editing", true)
     customUpdate(input(), true)
   }
 
+  const move = (step: number) => {
+    if (store.editing || sending()) return
+    focus(store.focus + step)
+  }
+
+  const nav = (event: KeyboardEvent) => {
+    if (event.defaultPrevented) return
+
+    if (event.key === "Escape") {
+      event.preventDefault()
+      void reject()
+      return
+    }
+
+    const mod = (event.metaKey || event.ctrlKey) && !event.altKey
+    if (mod && event.key === "Enter") {
+      if (event.repeat) return
+      event.preventDefault()
+      next()
+      return
+    }
+
+    const target =
+      event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
+    if (store.editing) return
+    if (!(target instanceof HTMLElement)) return
+    if (event.altKey || event.ctrlKey || event.metaKey) return
+
+    if (event.key === "ArrowDown" || event.key === "ArrowRight") {
+      event.preventDefault()
+      move(1)
+      return
+    }
+
+    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
+      event.preventDefault()
+      move(-1)
+      return
+    }
+
+    if (event.key === "Home") {
+      event.preventDefault()
+      focus(0)
+      return
+    }
+
+    if (event.key !== "End") return
+    event.preventDefault()
+    focus(count() - 1)
+  }
+
   const selectOption = (optIndex: number) => {
     if (sending()) return
 
@@ -219,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const opt = options()[optIndex]
     if (!opt) return
     if (multi()) {
+      setStore("editing", false)
       toggle(opt.label)
       return
     }
@@ -228,6 +369,25 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   const commitCustom = () => {
     setStore("editing", false)
     customUpdate(input())
+    focus(options().length)
+  }
+
+  const resizeInput = (el: HTMLTextAreaElement) => {
+    el.style.height = "0px"
+    el.style.height = `${el.scrollHeight}px`
+  }
+
+  const focusCustom = (el: HTMLTextAreaElement) => {
+    setTimeout(() => {
+      el.focus()
+      resizeInput(el)
+    }, 0)
+  }
+
+  const toggleCustomMark = (event: MouseEvent) => {
+    event.preventDefault()
+    event.stopPropagation()
+    customToggle()
   }
 
   const next = () => {
@@ -239,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       return
     }
 
-    setStore("tab", store.tab + 1)
+    const tab = store.tab + 1
+    setStore("tab", tab)
     setStore("editing", false)
+    focus(pickFocus(tab))
   }
 
   const back = () => {
     if (sending()) return
     if (store.tab <= 0) return
-    setStore("tab", store.tab - 1)
+    const tab = store.tab - 1
+    setStore("tab", tab)
     setStore("editing", false)
+    focus(pickFocus(tab))
   }
 
   const jump = (tab: number) => {
     if (sending()) return
     setStore("tab", tab)
     setStore("editing", false)
+    focus(pickFocus(tab))
   }
 
   return (
     <DockPrompt
       kind="question"
       ref={(el) => (root = el)}
+      onKeyDown={nav}
       header={
         <>
           <div data-slot="question-header-title">{summary()}</div>
@@ -270,10 +436,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                   type="button"
                   data-slot="question-progress-segment"
                   data-active={i() === store.tab}
-                  data-answered={
-                    (store.answers[i()]?.length ?? 0) > 0 ||
-                    (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
-                  }
+                  data-answered={answered(i())}
                   disabled={sending()}
                   onClick={() => jump(i())}
                   aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -285,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       }
       footer={
         <>
-          <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
+          <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
             {language.t("ui.common.dismiss")}
           </Button>
           <div data-slot="question-footer-actions">
@@ -294,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 {language.t("ui.common.back")}
               </Button>
             </Show>
-            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
+            <Button
+              variant={last() ? "primary" : "secondary"}
+              size="large"
+              disabled={sending()}
+              onClick={next}
+              aria-keyshortcuts="Meta+Enter Control+Enter"
+            >
               {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
             </Button>
           </div>
@@ -307,69 +476,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       </Show>
       <div data-slot="question-options">
         <For each={options()}>
-          {(opt, i) => {
-            const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
-            return (
-              <button
-                data-slot="question-option"
-                data-picked={picked()}
-                role={multi() ? "checkbox" : "radio"}
-                aria-checked={picked()}
-                disabled={sending()}
-                onClick={() => selectOption(i())}
-              >
-                <span data-slot="question-option-check" aria-hidden="true">
-                  <span
-                    data-slot="question-option-box"
-                    data-type={multi() ? "checkbox" : "radio"}
-                    data-picked={picked()}
-                  >
-                    <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
-                      <Icon name="check-small" size="small" />
-                    </Show>
-                  </span>
-                </span>
-                <span data-slot="question-option-main">
-                  <span data-slot="option-label">{opt.label}</span>
-                  <Show when={opt.description}>
-                    <span data-slot="option-description">{opt.description}</span>
-                  </Show>
-                </span>
-              </button>
-            )
-          }}
+          {(opt, i) => (
+            <Option
+              multi={multi()}
+              picked={picked(opt.label)}
+              label={opt.label}
+              description={opt.description}
+              disabled={sending()}
+              ref={(el) => (optsRef[i()] = el)}
+              onFocus={() => setStore("focus", i())}
+              onClick={() => selectOption(i())}
+            />
+          )}
         </For>
 
         <Show
           when={store.editing}
           fallback={
             <button
+              type="button"
+              ref={customRef}
               data-slot="question-option"
               data-custom="true"
               data-picked={on()}
               role={multi() ? "checkbox" : "radio"}
               aria-checked={on()}
               disabled={sending()}
+              onFocus={() => setStore("focus", options().length)}
               onClick={customOpen}
             >
-              <span
-                data-slot="question-option-check"
-                aria-hidden="true"
-                onClick={(e) => {
-                  e.preventDefault()
-                  e.stopPropagation()
-                  customToggle()
-                }}
-              >
-                <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
-                  <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
-                    <Icon name="check-small" size="small" />
-                  </Show>
-                </span>
-              </span>
+              <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
               <span data-slot="question-option-main">
-                <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
-                <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
+                <span data-slot="option-label">{customLabel()}</span>
+                <span data-slot="option-description">{input() || customPlaceholder()}</span>
               </span>
             </button>
           }
@@ -394,33 +533,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
               commitCustom()
             }}
           >
-            <span
-              data-slot="question-option-check"
-              aria-hidden="true"
-              onClick={(e) => {
-                e.preventDefault()
-                e.stopPropagation()
-                customToggle()
-              }}
-            >
-              <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
-                <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
-                  <Icon name="check-small" size="small" />
-                </Show>
-              </span>
-            </span>
+            <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
             <span data-slot="question-option-main">
-              <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+              <span data-slot="option-label">{customLabel()}</span>
               <textarea
-                ref={(el) =>
-                  setTimeout(() => {
-                    el.focus()
-                    el.style.height = "0px"
-                    el.style.height = `${el.scrollHeight}px`
-                  }, 0)
-                }
+                ref={focusCustom}
                 data-slot="question-custom-input"
-                placeholder={language.t("ui.question.custom.placeholder")}
+                placeholder={customPlaceholder()}
                 value={input()}
                 rows={1}
                 disabled={sending()}
@@ -428,16 +547,17 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                   if (e.key === "Escape") {
                     e.preventDefault()
                     setStore("editing", false)
+                    focus(options().length)
                     return
                   }
+                  if ((e.metaKey || e.ctrlKey) && !e.altKey) return
                   if (e.key !== "Enter" || e.shiftKey) return
                   e.preventDefault()
                   commitCustom()
                 }}
                 onInput={(e) => {
                   customUpdate(e.currentTarget.value)
-                  e.currentTarget.style.height = "0px"
-                  e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
+                  resizeInput(e.currentTarget)
                 }}
               />
             </span>

+ 145 - 131
packages/app/src/pages/session/file-tabs.tsx

@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
   )
 }
 
+type ScrollPos = { x: number; y: number }
+
+function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
+  let scroll: HTMLDivElement | undefined
+  let scrollFrame: number | undefined
+  let restoreFrame: number | undefined
+  let pending: ScrollPos | undefined
+  let code: HTMLElement[] = []
+
+  const getCode = () => {
+    const el = scroll
+    if (!el) return []
+
+    const host = el.querySelector("diffs-container")
+    if (!(host instanceof HTMLElement)) return []
+
+    const root = host.shadowRoot
+    if (!root) return []
+
+    return Array.from(root.querySelectorAll("[data-code]")).filter(
+      (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
+    )
+  }
+
+  const save = (next: ScrollPos) => {
+    pending = next
+    if (scrollFrame !== undefined) return
+
+    scrollFrame = requestAnimationFrame(() => {
+      scrollFrame = undefined
+
+      const out = pending
+      pending = undefined
+      if (!out) return
+
+      input.view().setScroll(input.tab(), out)
+    })
+  }
+
+  const onCodeScroll = (event: Event) => {
+    const el = scroll
+    if (!el) return
+
+    const target = event.currentTarget
+    if (!(target instanceof HTMLElement)) return
+
+    save({
+      x: target.scrollLeft,
+      y: el.scrollTop,
+    })
+  }
+
+  const sync = () => {
+    const next = getCode()
+    if (next.length === code.length && next.every((el, i) => el === code[i])) return
+
+    for (const item of code) {
+      item.removeEventListener("scroll", onCodeScroll)
+    }
+
+    code = next
+
+    for (const item of code) {
+      item.addEventListener("scroll", onCodeScroll)
+    }
+  }
+
+  const restore = () => {
+    const el = scroll
+    if (!el) return
+
+    const pos = input.view().scroll(input.tab())
+    if (!pos) return
+
+    sync()
+
+    if (code.length > 0) {
+      for (const item of code) {
+        if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
+      }
+    }
+
+    if (el.scrollTop !== pos.y) el.scrollTop = pos.y
+    if (code.length > 0) return
+    if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
+  }
+
+  const queueRestore = () => {
+    if (restoreFrame !== undefined) return
+
+    restoreFrame = requestAnimationFrame(() => {
+      restoreFrame = undefined
+      restore()
+    })
+  }
+
+  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+    if (code.length === 0) sync()
+
+    save({
+      x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+      y: event.currentTarget.scrollTop,
+    })
+  }
+
+  const setViewport = (el: HTMLDivElement) => {
+    scroll = el
+    restore()
+  }
+
+  onCleanup(() => {
+    for (const item of code) {
+      item.removeEventListener("scroll", onCodeScroll)
+    }
+
+    if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
+    if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
+  })
+
+  return {
+    handleScroll,
+    queueRestore,
+    setViewport,
+  }
+}
+
 export function FileTabContent(props: { tab: string }) {
   const file = useFile()
   const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
   }).activeFileTab
 
-  let scroll: HTMLDivElement | undefined
-  let scrollFrame: number | undefined
-  let restoreFrame: number | undefined
-  let pending: { x: number; y: number } | undefined
-  let codeScroll: HTMLElement[] = []
   let find: FileSearchHandle | null = null
 
   const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
     if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
     return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
   })
+  const scrollSync = createScrollSync({
+    tab: () => props.tab,
+    view,
+  })
 
   const selectionPreview = (source: string, selection: FileSelection) => {
     return previewSelectedLines(source, {
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
     })
   }
 
+  const buildPreview = (filePath: string, selection: FileSelection) => {
+    const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
+    if (!source) return undefined
+    return selectionPreview(source, selection)
+  }
+
   const addCommentToContext = (input: {
     file: string
     selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
     origin?: "review" | "file"
   }) => {
     const selection = selectionFromLines(input.selection)
-    const preview =
-      input.preview ??
-      (() => {
-        if (input.file === path()) return selectionPreview(contents(), selection)
-        const source = file.get(input.file)?.content?.content
-        if (!source) return undefined
-        return selectionPreview(source, selection)
-      })()
+    const preview = input.preview ?? buildPreview(input.file, selection)
 
     const saved = comments.add({
       file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
     comment: string
   }) => {
     comments.update(input.file, input.id, input.comment)
-    const preview =
-      input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
+    const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
     prompt.context.updateComment(input.file, input.id, {
       comment: input.comment,
       ...(preview ? { preview } : {}),
@@ -179,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
     comments: fileComments,
     label: language.t("ui.lineComment.submit"),
     draftKey: () => path() ?? props.tab,
+    mention: {
+      items: file.searchFilesAndDirectories,
+    },
     state: {
       opened: () => note.openedComment,
       setOpened: (id) => setNote("openedComment", id),
@@ -260,102 +386,6 @@ export function FileTabContent(props: { tab: string }) {
     requestAnimationFrame(() => comments.clearFocus())
   })
 
-  const getCodeScroll = () => {
-    const el = scroll
-    if (!el) return []
-
-    const host = el.querySelector("diffs-container")
-    if (!(host instanceof HTMLElement)) return []
-
-    const root = host.shadowRoot
-    if (!root) return []
-
-    return Array.from(root.querySelectorAll("[data-code]")).filter(
-      (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
-    )
-  }
-
-  const queueScrollUpdate = (next: { x: number; y: number }) => {
-    pending = next
-    if (scrollFrame !== undefined) return
-
-    scrollFrame = requestAnimationFrame(() => {
-      scrollFrame = undefined
-
-      const out = pending
-      pending = undefined
-      if (!out) return
-
-      view().setScroll(props.tab, out)
-    })
-  }
-
-  const handleCodeScroll = (event: Event) => {
-    const el = scroll
-    if (!el) return
-
-    const target = event.currentTarget
-    if (!(target instanceof HTMLElement)) return
-
-    queueScrollUpdate({
-      x: target.scrollLeft,
-      y: el.scrollTop,
-    })
-  }
-
-  const syncCodeScroll = () => {
-    const next = getCodeScroll()
-    if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
-
-    for (const item of codeScroll) {
-      item.removeEventListener("scroll", handleCodeScroll)
-    }
-
-    codeScroll = next
-
-    for (const item of codeScroll) {
-      item.addEventListener("scroll", handleCodeScroll)
-    }
-  }
-
-  const restoreScroll = () => {
-    const el = scroll
-    if (!el) return
-
-    const s = view().scroll(props.tab)
-    if (!s) return
-
-    syncCodeScroll()
-
-    if (codeScroll.length > 0) {
-      for (const item of codeScroll) {
-        if (item.scrollLeft !== s.x) item.scrollLeft = s.x
-      }
-    }
-
-    if (el.scrollTop !== s.y) el.scrollTop = s.y
-    if (codeScroll.length > 0) return
-    if (el.scrollLeft !== s.x) el.scrollLeft = s.x
-  }
-
-  const queueRestore = () => {
-    if (restoreFrame !== undefined) return
-
-    restoreFrame = requestAnimationFrame(() => {
-      restoreFrame = undefined
-      restoreScroll()
-    })
-  }
-
-  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-    if (codeScroll.length === 0) syncCodeScroll()
-
-    queueScrollUpdate({
-      x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
-      y: event.currentTarget.scrollTop,
-    })
-  }
-
   const cancelCommenting = () => {
     const p = path()
     if (p) file.setSelectedLines(p, null)
@@ -375,16 +405,7 @@ export function FileTabContent(props: { tab: string }) {
     const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
     prev = { loaded, ready, active }
     if (!restore) return
-    queueRestore()
-  })
-
-  onCleanup(() => {
-    for (const item of codeScroll) {
-      item.removeEventListener("scroll", handleCodeScroll)
-    }
-
-    if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
-    if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
+    scrollSync.queueRestore()
   })
 
   const renderFile = (source: string) => (
@@ -402,7 +423,7 @@ export function FileTabContent(props: { tab: string }) {
         selectedLines={activeSelection()}
         commentedLines={commentedLines()}
         onRendered={() => {
-          queueRestore()
+          scrollSync.queueRestore()
         }}
         annotations={commentsUi.annotations()}
         renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +441,7 @@ export function FileTabContent(props: { tab: string }) {
           mode: "auto",
           path: path(),
           current: state()?.content,
-          onLoad: queueRestore,
+          onLoad: scrollSync.queueRestore,
           onError: (args: { kind: "image" | "audio" | "svg" }) => {
             if (args.kind !== "svg") return
             showToast({
@@ -435,14 +456,7 @@ export function FileTabContent(props: { tab: string }) {
 
   return (
     <Tabs.Content value={props.tab} class="mt-3 relative h-full">
-      <ScrollView
-        class="h-full"
-        viewportRef={(el: HTMLDivElement) => {
-          scroll = el
-          restoreScroll()
-        }}
-        onScroll={handleScroll as any}
-      >
+      <ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
         <Switch>
           <Match when={state()?.loaded}>{renderFile(contents())}</Match>
           <Match when={state()?.loading}>

+ 4 - 0
packages/app/src/pages/session/review-tab.tsx

@@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
   onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
   focusedFile?: string
   onScrollRef?: (el: HTMLDivElement) => void
+  commentMentions?: {
+    items: (query: string) => string[] | Promise<string[]>
+  }
   classes?: {
     root?: string
     header?: string
@@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       onLineCommentUpdate={props.onLineCommentUpdate}
       onLineCommentDelete={props.onLineCommentDelete}
       lineCommentActions={props.lineCommentActions}
+      lineCommentMention={props.commentMentions}
       comments={props.comments}
       focusedComment={props.focusedComment}
       onFocusedCommentChange={props.onFocusedCommentChange}

+ 440 - 368
packages/app/src/pages/session/use-session-commands.tsx

@@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
     return permission.isAutoAcceptingDirectory(sdk.directory)
   }
-  command.register("session", () => {
-    const share =
-      sync.data.config.share === "disabled"
-        ? []
-        : [
-            sessionCommand({
-              id: "session.share",
-              title: info()?.share?.url
-                ? language.t("session.share.copy.copyLink")
-                : language.t("command.session.share"),
-              description: info()?.share?.url
-                ? language.t("toast.session.share.success.description")
-                : language.t("command.session.share.description"),
-              slash: "share",
-              disabled: !params.id,
-              onSelect: async () => {
-                if (!params.id) return
-
-                const write = (value: string) => {
-                  const body = typeof document === "undefined" ? undefined : document.body
-                  if (body) {
-                    const textarea = document.createElement("textarea")
-                    textarea.value = value
-                    textarea.setAttribute("readonly", "")
-                    textarea.style.position = "fixed"
-                    textarea.style.opacity = "0"
-                    textarea.style.pointerEvents = "none"
-                    body.appendChild(textarea)
-                    textarea.select()
-                    const copied = document.execCommand("copy")
-                    body.removeChild(textarea)
-                    if (copied) return Promise.resolve(true)
-                  }
-
-                  const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
-                  if (!clipboard?.writeText) return Promise.resolve(false)
-                  return clipboard.writeText(value).then(
-                    () => true,
-                    () => false,
-                  )
-                }
-
-                const copy = async (url: string, existing: boolean) => {
-                  const ok = await write(url)
-                  if (!ok) {
-                    showToast({
-                      title: language.t("toast.session.share.copyFailed.title"),
-                      variant: "error",
-                    })
-                    return
-                  }
-
-                  showToast({
-                    title: existing
-                      ? language.t("session.share.copy.copied")
-                      : language.t("toast.session.share.success.title"),
-                    description: language.t("toast.session.share.success.description"),
-                    variant: "success",
-                  })
-                }
-
-                const existing = info()?.share?.url
-                if (existing) {
-                  await copy(existing, true)
-                  return
-                }
-
-                const url = await sdk.client.session
-                  .share({ sessionID: params.id })
-                  .then((res) => res.data?.share?.url)
-                  .catch(() => undefined)
-                if (!url) {
-                  showToast({
-                    title: language.t("toast.session.share.failed.title"),
-                    description: language.t("toast.session.share.failed.description"),
-                    variant: "error",
-                  })
-                  return
-                }
-
-                await copy(url, false)
-              },
-            }),
-            sessionCommand({
-              id: "session.unshare",
-              title: language.t("command.session.unshare"),
-              description: language.t("command.session.unshare.description"),
-              slash: "unshare",
-              disabled: !params.id || !info()?.share?.url,
-              onSelect: async () => {
-                if (!params.id) return
-                await sdk.client.session
-                  .unshare({ sessionID: params.id })
-                  .then(() =>
-                    showToast({
-                      title: language.t("toast.session.unshare.success.title"),
-                      description: language.t("toast.session.unshare.success.description"),
-                      variant: "success",
-                    }),
-                  )
-                  .catch(() =>
-                    showToast({
-                      title: language.t("toast.session.unshare.failed.title"),
-                      description: language.t("toast.session.unshare.failed.description"),
-                      variant: "error",
-                    }),
-                  )
-              },
-            }),
-          ]
+  const write = async (value: string) => {
+    const body = typeof document === "undefined" ? undefined : document.body
+    if (body) {
+      const textarea = document.createElement("textarea")
+      textarea.value = value
+      textarea.setAttribute("readonly", "")
+      textarea.style.position = "fixed"
+      textarea.style.opacity = "0"
+      textarea.style.pointerEvents = "none"
+      body.appendChild(textarea)
+      textarea.select()
+      const copied = document.execCommand("copy")
+      body.removeChild(textarea)
+      if (copied) return true
+    }
 
+    const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
+    if (!clipboard?.writeText) return false
+    return clipboard.writeText(value).then(
+      () => true,
+      () => false,
+    )
+  }
+
+  const copyShare = async (url: string, existing: boolean) => {
+    if (!(await write(url))) {
+      showToast({
+        title: language.t("toast.session.share.copyFailed.title"),
+        variant: "error",
+      })
+      return
+    }
+
+    showToast({
+      title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
+      description: language.t("toast.session.share.success.description"),
+      variant: "success",
+    })
+  }
+
+  const share = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    const existing = info()?.share?.url
+    if (existing) {
+      await copyShare(existing, true)
+      return
+    }
+
+    const url = await sdk.client.session
+      .share({ sessionID })
+      .then((res) => res.data?.share?.url)
+      .catch(() => undefined)
+    if (!url) {
+      showToast({
+        title: language.t("toast.session.share.failed.title"),
+        description: language.t("toast.session.share.failed.description"),
+        variant: "error",
+      })
+      return
+    }
+
+    await copyShare(url, false)
+  }
+
+  const unshare = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    await sdk.client.session
+      .unshare({ sessionID })
+      .then(() =>
+        showToast({
+          title: language.t("toast.session.unshare.success.title"),
+          description: language.t("toast.session.unshare.success.description"),
+          variant: "success",
+        }),
+      )
+      .catch(() =>
+        showToast({
+          title: language.t("toast.session.unshare.failed.title"),
+          description: language.t("toast.session.unshare.failed.description"),
+          variant: "error",
+        }),
+      )
+  }
+
+  const openFile = () => {
+    void import("@/components/dialog-select-file").then((x) => {
+      dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
+    })
+  }
+
+  const closeTab = () => {
+    const tab = closableTab()
+    if (!tab) return
+    tabs().close(tab)
+  }
+
+  const addSelection = () => {
+    const tab = activeFileTab()
+    if (!tab) return
+
+    const path = file.pathFromTab(tab)
+    if (!path) return
+
+    const range = file.selectedLines(path) as SelectedLineRange | null | undefined
+    if (!range) {
+      showToast({
+        title: language.t("toast.context.noLineSelection.title"),
+        description: language.t("toast.context.noLineSelection.description"),
+      })
+      return
+    }
+
+    addSelectionToContext(path, selectionFromLines(range))
+  }
+
+  const openTerminal = () => {
+    if (terminal.all().length > 0) terminal.new()
+    view().terminal.open()
+  }
+
+  const chooseModel = () => {
+    void import("@/components/dialog-select-model").then((x) => {
+      dialog.show(() => <x.DialogSelectModel model={local.model} />)
+    })
+  }
+
+  const chooseMcp = () => {
+    void import("@/components/dialog-select-mcp").then((x) => {
+      dialog.show(() => <x.DialogSelectMcp />)
+    })
+  }
+
+  const toggleAutoAccept = () => {
+    const sessionID = params.id
+    if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
+    else permission.toggleAutoAcceptDirectory(sdk.directory)
+
+    const active = sessionID
+      ? permission.isAutoAccepting(sessionID, sdk.directory)
+      : permission.isAutoAcceptingDirectory(sdk.directory)
+    showToast({
+      title: active
+        ? language.t("toast.permissions.autoaccept.on.title")
+        : language.t("toast.permissions.autoaccept.off.title"),
+      description: active
+        ? language.t("toast.permissions.autoaccept.on.description")
+        : language.t("toast.permissions.autoaccept.off.description"),
+    })
+  }
+
+  const undo = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    if (status().type !== "idle") {
+      await sdk.client.session.abort({ sessionID }).catch(() => {})
+    }
+
+    const revert = info()?.revert?.messageID
+    const message = findLast(userMessages(), (x) => !revert || x.id < revert)
+    if (!message) return
+
+    await sdk.client.session.revert({ sessionID, messageID: message.id })
+    const parts = sync.data.part[message.id]
+    if (parts) {
+      const restored = extractPromptFromParts(parts, { directory: sdk.directory })
+      prompt.set(restored)
+    }
+
+    const prev = findLast(userMessages(), (x) => x.id < message.id)
+    setActiveMessage(prev)
+  }
+
+  const redo = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    const revertMessageID = info()?.revert?.messageID
+    if (!revertMessageID) return
+
+    const next = userMessages().find((x) => x.id > revertMessageID)
+    if (!next) {
+      await sdk.client.session.unrevert({ sessionID })
+      prompt.reset()
+      const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
+      setActiveMessage(last)
+      return
+    }
+
+    await sdk.client.session.revert({ sessionID, messageID: next.id })
+    const prev = findLast(userMessages(), (x) => x.id < next.id)
+    setActiveMessage(prev)
+  }
+
+  const compact = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    const model = local.model.current()
+    if (!model) {
+      showToast({
+        title: language.t("toast.model.none.title"),
+        description: language.t("toast.model.none.description"),
+      })
+      return
+    }
+
+    await sdk.client.session.summarize({
+      sessionID,
+      modelID: model.id,
+      providerID: model.provider.id,
+    })
+  }
+
+  const fork = () => {
+    void import("@/components/dialog-fork").then((x) => {
+      dialog.show(() => <x.DialogFork />)
+    })
+  }
+
+  const shareCmds = () => {
+    if (sync.data.config.share === "disabled") return []
     return [
       sessionCommand({
-        id: "session.new",
-        title: language.t("command.session.new"),
-        keybind: "mod+shift+s",
-        slash: "new",
-        onSelect: () => navigate(`/${params.dir}/session`),
-      }),
-      fileCommand({
-        id: "file.open",
-        title: language.t("command.file.open"),
-        description: language.t("palette.search.placeholder"),
-        keybind: "mod+k,mod+p",
-        slash: "open",
-        onSelect: () => {
-          void import("@/components/dialog-select-file").then((x) => {
-            dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
-          })
-        },
-      }),
-      fileCommand({
-        id: "tab.close",
-        title: language.t("command.tab.close"),
-        keybind: "mod+w",
-        disabled: !closableTab(),
-        onSelect: () => {
-          const tab = closableTab()
-          if (!tab) return
-          tabs().close(tab)
-        },
-      }),
-      contextCommand({
-        id: "context.addSelection",
-        title: language.t("command.context.addSelection"),
-        description: language.t("command.context.addSelection.description"),
-        keybind: "mod+shift+l",
-        disabled: !canAddSelectionContext(),
-        onSelect: () => {
-          const tab = activeFileTab()
-          if (!tab) return
-          const path = file.pathFromTab(tab)
-          if (!path) return
-
-          const range = file.selectedLines(path) as SelectedLineRange | null | undefined
-          if (!range) {
-            showToast({
-              title: language.t("toast.context.noLineSelection.title"),
-              description: language.t("toast.context.noLineSelection.description"),
-            })
-            return
-          }
-
-          addSelectionToContext(path, selectionFromLines(range))
-        },
-      }),
-      viewCommand({
-        id: "terminal.toggle",
-        title: language.t("command.terminal.toggle"),
-        keybind: "ctrl+`",
-        slash: "terminal",
-        onSelect: () => view().terminal.toggle(),
-      }),
-      viewCommand({
-        id: "review.toggle",
-        title: language.t("command.review.toggle"),
-        keybind: "mod+shift+r",
-        onSelect: () => view().reviewPanel.toggle(),
-      }),
-      viewCommand({
-        id: "fileTree.toggle",
-        title: language.t("command.fileTree.toggle"),
-        keybind: "mod+\\",
-        onSelect: () => layout.fileTree.toggle(),
-      }),
-      viewCommand({
-        id: "input.focus",
-        title: language.t("command.input.focus"),
-        keybind: "ctrl+l",
-        onSelect: focusInput,
-      }),
-      terminalCommand({
-        id: "terminal.new",
-        title: language.t("command.terminal.new"),
-        description: language.t("command.terminal.new.description"),
-        keybind: "ctrl+alt+t",
-        onSelect: () => {
-          if (terminal.all().length > 0) terminal.new()
-          view().terminal.open()
-        },
-      }),
-      sessionCommand({
-        id: "message.previous",
-        title: language.t("command.message.previous"),
-        description: language.t("command.message.previous.description"),
-        keybind: "mod+alt+[",
+        id: "session.share",
+        title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
+        description: info()?.share?.url
+          ? language.t("toast.session.share.success.description")
+          : language.t("command.session.share.description"),
+        slash: "share",
         disabled: !params.id,
-        onSelect: () => navigateMessageByOffset(-1),
+        onSelect: share,
       }),
       sessionCommand({
-        id: "message.next",
-        title: language.t("command.message.next"),
-        description: language.t("command.message.next.description"),
-        keybind: "mod+alt+]",
-        disabled: !params.id,
-        onSelect: () => navigateMessageByOffset(1),
-      }),
-      modelCommand({
-        id: "model.choose",
-        title: language.t("command.model.choose"),
-        description: language.t("command.model.choose.description"),
-        keybind: "mod+'",
-        slash: "model",
-        onSelect: () => {
-          void import("@/components/dialog-select-model").then((x) => {
-            dialog.show(() => <x.DialogSelectModel model={local.model} />)
-          })
-        },
-      }),
-      mcpCommand({
-        id: "mcp.toggle",
-        title: language.t("command.mcp.toggle"),
-        description: language.t("command.mcp.toggle.description"),
-        keybind: "mod+;",
-        slash: "mcp",
-        onSelect: () => {
-          void import("@/components/dialog-select-mcp").then((x) => {
-            dialog.show(() => <x.DialogSelectMcp />)
-          })
-        },
-      }),
-      agentCommand({
-        id: "agent.cycle",
-        title: language.t("command.agent.cycle"),
-        description: language.t("command.agent.cycle.description"),
-        keybind: "mod+.",
-        slash: "agent",
-        onSelect: () => local.agent.move(1),
-      }),
-      agentCommand({
-        id: "agent.cycle.reverse",
-        title: language.t("command.agent.cycle.reverse"),
-        description: language.t("command.agent.cycle.reverse.description"),
-        keybind: "shift+mod+.",
-        onSelect: () => local.agent.move(-1),
+        id: "session.unshare",
+        title: language.t("command.session.unshare"),
+        description: language.t("command.session.unshare.description"),
+        slash: "unshare",
+        disabled: !params.id || !info()?.share?.url,
+        onSelect: unshare,
       }),
-      modelCommand({
-        id: "model.variant.cycle",
-        title: language.t("command.model.variant.cycle"),
-        description: language.t("command.model.variant.cycle.description"),
-        keybind: "shift+mod+d",
-        onSelect: () => local.model.variant.cycle(),
-      }),
-      permissionsCommand({
-        id: "permissions.autoaccept",
-        title: isAutoAcceptActive()
-          ? language.t("command.permissions.autoaccept.disable")
-          : language.t("command.permissions.autoaccept.enable"),
-        keybind: "mod+shift+a",
-        disabled: false,
-        onSelect: () => {
-          const sessionID = params.id
-          if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
-          else permission.toggleAutoAcceptDirectory(sdk.directory)
-
-          const active = sessionID
-            ? permission.isAutoAccepting(sessionID, sdk.directory)
-            : permission.isAutoAcceptingDirectory(sdk.directory)
-          showToast({
-            title: active
-              ? language.t("toast.permissions.autoaccept.on.title")
-              : language.t("toast.permissions.autoaccept.off.title"),
-            description: active
-              ? language.t("toast.permissions.autoaccept.on.description")
-              : language.t("toast.permissions.autoaccept.off.description"),
-          })
-        },
-      }),
-      sessionCommand({
-        id: "session.undo",
-        title: language.t("command.session.undo"),
-        description: language.t("command.session.undo.description"),
-        slash: "undo",
-        disabled: !params.id || visibleUserMessages().length === 0,
-        onSelect: async () => {
-          const sessionID = params.id
-          if (!sessionID) return
-          if (status().type !== "idle") {
-            await sdk.client.session.abort({ sessionID }).catch(() => {})
-          }
-          const revert = info()?.revert?.messageID
-          const message = findLast(userMessages(), (x) => !revert || x.id < revert)
-          if (!message) return
-          await sdk.client.session.revert({ sessionID, messageID: message.id })
-          const parts = sync.data.part[message.id]
-          if (parts) {
-            const restored = extractPromptFromParts(parts, { directory: sdk.directory })
-            prompt.set(restored)
-          }
-          const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
-          setActiveMessage(priorMessage)
-        },
-      }),
-      sessionCommand({
-        id: "session.redo",
-        title: language.t("command.session.redo"),
-        description: language.t("command.session.redo.description"),
-        slash: "redo",
-        disabled: !params.id || !info()?.revert?.messageID,
-        onSelect: async () => {
-          const sessionID = params.id
-          if (!sessionID) return
-          const revertMessageID = info()?.revert?.messageID
-          if (!revertMessageID) return
-          const nextMessage = userMessages().find((x) => x.id > revertMessageID)
-          if (!nextMessage) {
-            await sdk.client.session.unrevert({ sessionID })
-            prompt.reset()
-            const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
-            setActiveMessage(lastMsg)
-            return
-          }
-          await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
-          const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
-          setActiveMessage(priorMsg)
-        },
-      }),
-      sessionCommand({
-        id: "session.compact",
-        title: language.t("command.session.compact"),
-        description: language.t("command.session.compact.description"),
-        slash: "compact",
-        disabled: !params.id || visibleUserMessages().length === 0,
-        onSelect: async () => {
-          const sessionID = params.id
-          if (!sessionID) return
-          const model = local.model.current()
-          if (!model) {
-            showToast({
-              title: language.t("toast.model.none.title"),
-              description: language.t("toast.model.none.description"),
-            })
-            return
-          }
-          await sdk.client.session.summarize({
-            sessionID,
-            modelID: model.id,
-            providerID: model.provider.id,
-          })
-        },
-      }),
-      sessionCommand({
-        id: "session.fork",
-        title: language.t("command.session.fork"),
-        description: language.t("command.session.fork.description"),
-        slash: "fork",
-        disabled: !params.id || visibleUserMessages().length === 0,
-        onSelect: () => {
-          void import("@/components/dialog-fork").then((x) => {
-            dialog.show(() => <x.DialogFork />)
-          })
-        },
-      }),
-      ...share,
     ]
-  })
+  }
+
+  const sessionCmds = () => [
+    sessionCommand({
+      id: "session.new",
+      title: language.t("command.session.new"),
+      keybind: "mod+shift+s",
+      slash: "new",
+      onSelect: () => navigate(`/${params.dir}/session`),
+    }),
+    sessionCommand({
+      id: "session.undo",
+      title: language.t("command.session.undo"),
+      description: language.t("command.session.undo.description"),
+      slash: "undo",
+      disabled: !params.id || visibleUserMessages().length === 0,
+      onSelect: undo,
+    }),
+    sessionCommand({
+      id: "session.redo",
+      title: language.t("command.session.redo"),
+      description: language.t("command.session.redo.description"),
+      slash: "redo",
+      disabled: !params.id || !info()?.revert?.messageID,
+      onSelect: redo,
+    }),
+    sessionCommand({
+      id: "session.compact",
+      title: language.t("command.session.compact"),
+      description: language.t("command.session.compact.description"),
+      slash: "compact",
+      disabled: !params.id || visibleUserMessages().length === 0,
+      onSelect: compact,
+    }),
+    sessionCommand({
+      id: "session.fork",
+      title: language.t("command.session.fork"),
+      description: language.t("command.session.fork.description"),
+      slash: "fork",
+      disabled: !params.id || visibleUserMessages().length === 0,
+      onSelect: fork,
+    }),
+  ]
+
+  const fileCmds = () => [
+    fileCommand({
+      id: "file.open",
+      title: language.t("command.file.open"),
+      description: language.t("palette.search.placeholder"),
+      keybind: "mod+k,mod+p",
+      slash: "open",
+      onSelect: openFile,
+    }),
+    fileCommand({
+      id: "tab.close",
+      title: language.t("command.tab.close"),
+      keybind: "mod+w",
+      disabled: !closableTab(),
+      onSelect: closeTab,
+    }),
+  ]
+
+  const contextCmds = () => [
+    contextCommand({
+      id: "context.addSelection",
+      title: language.t("command.context.addSelection"),
+      description: language.t("command.context.addSelection.description"),
+      keybind: "mod+shift+l",
+      disabled: !canAddSelectionContext(),
+      onSelect: addSelection,
+    }),
+  ]
+
+  const viewCmds = () => [
+    viewCommand({
+      id: "terminal.toggle",
+      title: language.t("command.terminal.toggle"),
+      keybind: "ctrl+`",
+      slash: "terminal",
+      onSelect: () => view().terminal.toggle(),
+    }),
+    viewCommand({
+      id: "review.toggle",
+      title: language.t("command.review.toggle"),
+      keybind: "mod+shift+r",
+      onSelect: () => view().reviewPanel.toggle(),
+    }),
+    viewCommand({
+      id: "fileTree.toggle",
+      title: language.t("command.fileTree.toggle"),
+      keybind: "mod+\\",
+      onSelect: () => layout.fileTree.toggle(),
+    }),
+    viewCommand({
+      id: "input.focus",
+      title: language.t("command.input.focus"),
+      keybind: "ctrl+l",
+      onSelect: focusInput,
+    }),
+  ]
+
+  const terminalCmds = () => [
+    terminalCommand({
+      id: "terminal.new",
+      title: language.t("command.terminal.new"),
+      description: language.t("command.terminal.new.description"),
+      keybind: "ctrl+alt+t",
+      onSelect: openTerminal,
+    }),
+  ]
+
+  const messageCmds = () => [
+    sessionCommand({
+      id: "message.previous",
+      title: language.t("command.message.previous"),
+      description: language.t("command.message.previous.description"),
+      keybind: "mod+alt+[",
+      disabled: !params.id,
+      onSelect: () => navigateMessageByOffset(-1),
+    }),
+    sessionCommand({
+      id: "message.next",
+      title: language.t("command.message.next"),
+      description: language.t("command.message.next.description"),
+      keybind: "mod+alt+]",
+      disabled: !params.id,
+      onSelect: () => navigateMessageByOffset(1),
+    }),
+  ]
+
+  const modelCmds = () => [
+    modelCommand({
+      id: "model.choose",
+      title: language.t("command.model.choose"),
+      description: language.t("command.model.choose.description"),
+      keybind: "mod+'",
+      slash: "model",
+      onSelect: chooseModel,
+    }),
+    modelCommand({
+      id: "model.variant.cycle",
+      title: language.t("command.model.variant.cycle"),
+      description: language.t("command.model.variant.cycle.description"),
+      keybind: "shift+mod+d",
+      onSelect: () => local.model.variant.cycle(),
+    }),
+  ]
+
+  const mcpCmds = () => [
+    mcpCommand({
+      id: "mcp.toggle",
+      title: language.t("command.mcp.toggle"),
+      description: language.t("command.mcp.toggle.description"),
+      keybind: "mod+;",
+      slash: "mcp",
+      onSelect: chooseMcp,
+    }),
+  ]
+
+  const agentCmds = () => [
+    agentCommand({
+      id: "agent.cycle",
+      title: language.t("command.agent.cycle"),
+      description: language.t("command.agent.cycle.description"),
+      keybind: "mod+.",
+      slash: "agent",
+      onSelect: () => local.agent.move(1),
+    }),
+    agentCommand({
+      id: "agent.cycle.reverse",
+      title: language.t("command.agent.cycle.reverse"),
+      description: language.t("command.agent.cycle.reverse.description"),
+      keybind: "shift+mod+.",
+      onSelect: () => local.agent.move(-1),
+    }),
+  ]
+
+  const permissionsCmds = () => [
+    permissionsCommand({
+      id: "permissions.autoaccept",
+      title: isAutoAcceptActive()
+        ? language.t("command.permissions.autoaccept.disable")
+        : language.t("command.permissions.autoaccept.enable"),
+      keybind: "mod+shift+a",
+      disabled: false,
+      onSelect: toggleAutoAccept,
+    }),
+  ]
+
+  command.register("session", () => [
+    ...sessionCmds(),
+    ...shareCmds(),
+    ...fileCmds(),
+    ...contextCmds(),
+    ...viewCmds(),
+    ...terminalCmds(),
+    ...messageCmds(),
+    ...modelCmds(),
+    ...mcpCmds(),
+    ...agentCmds(),
+    ...permissionsCmds(),
+  ])
 }

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-app",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 2
packages/console/app/src/i18n/ar.ts

@@ -331,8 +331,7 @@ export const dict = {
   "go.faq.a4.p3": "ألغِ في أي وقت.",
   "go.faq.q5": "ماذا عن البيانات والخصوصية؟",
   "go.faq.a5.body":
-    "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
-  "go.faq.a5.contactAfter": "إذا كان لديك أي أسئلة.",
+    "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
   "go.faq.a5.beforeExceptions":
     "تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
   "go.faq.a5.exceptionsLink": "الاستثناءات التالية",

+ 1 - 2
packages/console/app/src/i18n/br.ts

@@ -338,8 +338,7 @@ export const dict = {
   "go.faq.a4.p3": "Cancele a qualquer momento.",
   "go.faq.q5": "E sobre dados e privacidade?",
   "go.faq.a5.body":
-    "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
-  "go.faq.a5.contactAfter": "se você tiver alguma dúvida.",
+    "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
   "go.faq.a5.beforeExceptions":
     "Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
   "go.faq.a5.exceptionsLink": "seguintes exceções",

+ 1 - 2
packages/console/app/src/i18n/da.ts

@@ -336,8 +336,7 @@ export const dict = {
   "go.faq.a4.p3": "Annuller til enhver tid.",
   "go.faq.q5": "Hvad med data og privatliv?",
   "go.faq.a5.body":
-    "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
-  "go.faq.a5.contactAfter": "hvis du har spørgsmål.",
+    "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
   "go.faq.a5.beforeExceptions":
     "Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
   "go.faq.a5.exceptionsLink": "følgende undtagelser",

+ 1 - 2
packages/console/app/src/i18n/de.ts

@@ -338,8 +338,7 @@ export const dict = {
   "go.faq.a4.p3": "Jederzeit kündbar.",
   "go.faq.q5": "Was ist mit Daten und Privatsphäre?",
   "go.faq.a5.body":
-    "Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
-  "go.faq.a5.contactAfter": "wenn du Fragen hast.",
+    "Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
   "go.faq.a5.beforeExceptions":
     "Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
   "go.faq.a5.exceptionsLink": "folgenden Ausnahmen",

+ 2 - 2
packages/console/app/src/i18n/en.ts

@@ -331,8 +331,8 @@ export const dict = {
   "go.faq.a4.p3": "Cancel any time.",
   "go.faq.q5": "What about data and privacy?",
   "go.faq.a5.body":
-    "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
-  "go.faq.a5.contactAfter": "if you have any questions.",
+    "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
+
   "go.faq.a5.beforeExceptions":
     "Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
   "go.faq.a5.exceptionsLink": "following exceptions",

+ 1 - 2
packages/console/app/src/i18n/es.ts

@@ -338,8 +338,7 @@ export const dict = {
   "go.faq.a4.p3": "Cancela en cualquier momento.",
   "go.faq.q5": "¿Qué pasa con los datos y la privacidad?",
   "go.faq.a5.body":
-    "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
-  "go.faq.a5.contactAfter": "si tienes alguna pregunta.",
+    "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.",
   "go.faq.a5.beforeExceptions":
     "Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
   "go.faq.a5.exceptionsLink": "siguientes excepciones",

+ 1 - 2
packages/console/app/src/i18n/fr.ts

@@ -340,8 +340,7 @@ export const dict = {
   "go.faq.a4.p3": "Annulez à tout moment.",
   "go.faq.q5": "Et pour les données et la confidentialité ?",
   "go.faq.a5.body":
-    "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.",
-  "go.faq.a5.contactAfter": "si vous avez des questions.",
+    "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles.",
   "go.faq.a5.beforeExceptions":
     "Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
   "go.faq.a5.exceptionsLink": "exceptions suivantes",

+ 1 - 2
packages/console/app/src/i18n/it.ts

@@ -334,8 +334,7 @@ export const dict = {
   "go.faq.a4.p3": "Annulla in qualsiasi momento.",
   "go.faq.q5": "E per quanto riguarda dati e privacy?",
   "go.faq.a5.body":
-    "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile.",
-  "go.faq.a5.contactAfter": "se hai domande.",
+    "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli.",
   "go.faq.a5.beforeExceptions":
     "I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
   "go.faq.a5.exceptionsLink": "seguenti eccezioni",

+ 1 - 2
packages/console/app/src/i18n/ja.ts

@@ -335,8 +335,7 @@ export const dict = {
   "go.faq.a4.p3": "いつでもキャンセル可能です。",
   "go.faq.q5": "データとプライバシーは?",
   "go.faq.a5.body":
-    "このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。",
-  "go.faq.a5.contactAfter": "ご質問がございましたら。",
+    "このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。プロバイダーはゼロ保持ポリシーに従い、お客様のデータをモデルのトレーニングに使用しません。",
   "go.faq.a5.beforeExceptions":
     "Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
   "go.faq.a5.exceptionsLink": "以下の例外",

+ 1 - 2
packages/console/app/src/i18n/ko.ts

@@ -331,8 +331,7 @@ export const dict = {
   "go.faq.a4.p3": "언제든지 취소할 수 있습니다.",
   "go.faq.q5": "데이터와 프라이버시는 어떤가요?",
   "go.faq.a5.body":
-    "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다.",
-  "go.faq.a5.contactAfter": "질문이 있으시면 언제든지 문의해 주세요.",
+    "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다. 당사의 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다.",
   "go.faq.a5.beforeExceptions":
     "Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
   "go.faq.a5.exceptionsLink": "다음 예외",

+ 1 - 2
packages/console/app/src/i18n/no.ts

@@ -335,8 +335,7 @@ export const dict = {
   "go.faq.a4.p3": "Avslutt når som helst.",
   "go.faq.q5": "Hva med data og personvern?",
   "go.faq.a5.body":
-    "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang.",
-  "go.faq.a5.contactAfter": "hvis du har spørsmål.",
+    "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Våre leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening.",
   "go.faq.a5.beforeExceptions":
     "Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
   "go.faq.a5.exceptionsLink": "følgende unntak",

+ 1 - 2
packages/console/app/src/i18n/pl.ts

@@ -336,8 +336,7 @@ export const dict = {
   "go.faq.a4.p3": "Anuluj w dowolnym momencie.",
   "go.faq.q5": "A co z danymi i prywatnością?",
   "go.faq.a5.body":
-    "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp.",
-  "go.faq.a5.contactAfter": "jeśli masz jakiekolwiek pytania.",
+    "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Nasi dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli.",
   "go.faq.a5.beforeExceptions":
     "Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z",
   "go.faq.a5.exceptionsLink": "następującymi wyjątkami",

+ 1 - 2
packages/console/app/src/i18n/ru.ts

@@ -340,8 +340,7 @@ export const dict = {
   "go.faq.a4.p3": "Отмена в любое время.",
   "go.faq.q5": "Как насчет данных и приватности?",
   "go.faq.a5.body":
-    "План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа.",
-  "go.faq.a5.contactAfter": "если у вас есть вопросы.",
+    "План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа. Наши провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей.",
   "go.faq.a5.beforeExceptions":
     "Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
   "go.faq.a5.exceptionsLink": "следующими исключениями",

+ 1 - 2
packages/console/app/src/i18n/th.ts

@@ -333,8 +333,7 @@ export const dict = {
   "go.faq.a4.p3": "ยกเลิกได้ตลอดเวลา",
   "go.faq.q5": "แล้วเรื่องข้อมูลและความเป็นส่วนตัวล่ะ?",
   "go.faq.a5.body":
-    "แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร",
-  "go.faq.a5.contactAfter": "หากคุณมีคำถามใดๆ",
+    "แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร ผู้ให้บริการของเราปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูลและไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล",
   "go.faq.a5.beforeExceptions":
     "โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
   "go.faq.a5.exceptionsLink": "ข้อยกเว้นดังนี้",

+ 1 - 2
packages/console/app/src/i18n/tr.ts

@@ -339,8 +339,7 @@ export const dict = {
   "go.faq.a4.p3": "yönetebilirsiniz. İstediğiniz zaman iptal edin.",
   "go.faq.q5": "Veri ve gizlilik ne olacak?",
   "go.faq.a5.body":
-    "Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır.",
-  "go.faq.a5.contactAfter": "herhangi bir sorunuz varsa.",
+    "Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz.",
   "go.faq.a5.beforeExceptions":
     "Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
   "go.faq.a5.exceptionsLink": "aşağıdaki istisnalar",

+ 2 - 2
packages/console/app/src/i18n/zh.ts

@@ -319,8 +319,8 @@ export const dict = {
   "go.faq.a4.p2.accountLink": "账户",
   "go.faq.a4.p3": "中管理订阅。随时取消。",
   "go.faq.q5": "数据和隐私如何?",
-  "go.faq.a5.body": "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。",
-  "go.faq.a5.contactAfter": "如果您有任何问题。",
+  "go.faq.a5.body":
+    "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。我们的提供商遵循零留存政策,不使用您的数据进行模型训练。",
   "go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
   "go.faq.a5.exceptionsLink": "以下例外情况除外",
   "go.faq.q6": "我可以充值余额吗?",

+ 2 - 2
packages/console/app/src/i18n/zht.ts

@@ -319,8 +319,8 @@ export const dict = {
   "go.faq.a4.p2.accountLink": "帳戶",
   "go.faq.a4.p3": "中管理訂閱。隨時取消。",
   "go.faq.q5": "資料與隱私怎麼辦?",
-  "go.faq.a5.body": "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。",
-  "go.faq.a5.contactAfter": "如果你有任何問題。",
+  "go.faq.a5.body":
+    "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。我們的供應商遵循零留存政策,不會將你的資料用於模型訓練。",
   "go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有",
   "go.faq.a5.exceptionsLink": "以下例外",
   "go.faq.q6": "我可以儲值額度嗎?",

+ 1 - 4
packages/console/app/src/routes/go/index.tsx

@@ -432,10 +432,7 @@ export default function Home() {
                 </Faq>
               </li>
               <li>
-                <Faq question={i18n.t("go.faq.q5")}>
-                  {i18n.t("go.faq.a5.body")} <a href="mailto:[email protected]">{i18n.t("common.contactUs")}</a>{" "}
-                  {i18n.t("go.faq.a5.contactAfter")}
-                </Faq>
+                <Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
               </li>
               <li>
                 <Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>

+ 19 - 20
packages/console/app/src/routes/zen/util/handler.ts

@@ -139,19 +139,16 @@ export async function handler(
       const startTimestamp = Date.now()
       const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
       const reqBody = JSON.stringify(
-        providerInfo.modifyBody(
-          {
-            ...createBodyConverter(opts.format, providerInfo.format)(body),
-            model: providerInfo.model,
-            ...(providerInfo.payloadModifier ?? {}),
-            ...Object.fromEntries(
-              Object.entries(providerInfo.payloadMappings ?? {})
-                .map(([k, v]) => [k, input.request.headers.get(v)])
-                .filter(([_k, v]) => !!v),
-            ),
-          },
-          authInfo?.workspaceID,
-        ),
+        providerInfo.modifyBody({
+          ...createBodyConverter(opts.format, providerInfo.format)(body),
+          model: providerInfo.model,
+          ...(providerInfo.payloadModifier ?? {}),
+          ...Object.fromEntries(
+            Object.entries(providerInfo.payloadMappings ?? {})
+              .map(([k, v]) => [k, input.request.headers.get(v)])
+              .filter(([_k, v]) => !!v),
+          ),
+        }),
       )
       logger.debug("REQUEST URL: " + reqUrl)
       logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
@@ -470,15 +467,17 @@ export async function handler(
       ...(() => {
         const providerProps = zenData.providers[modelProvider.id]
         const format = providerProps.format
-        const providerModel = modelProvider.model
-        if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
-        if (format === "google") return googleHelper({ reqModel, providerModel })
-        if (format === "openai") return openaiHelper({ reqModel, providerModel })
-        return oaCompatHelper({
+        const opts = {
           reqModel,
-          providerModel,
+          providerModel: modelProvider.model,
           adjustCacheUsage: providerProps.adjustCacheUsage,
-        })
+          safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
+          workspaceID: authInfo?.workspaceID,
+        }
+        if (format === "anthropic") return anthropicHelper(opts)
+        if (format === "google") return googleHelper(opts)
+        if (format === "openai") return openaiHelper(opts)
+        return oaCompatHelper(opts)
       })(),
     }
   }

+ 3 - 2
packages/console/app/src/routes/zen/util/provider/openai-compatible.ts

@@ -21,17 +21,18 @@ type Usage = {
   }
 }
 
-export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
+export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
   format: "oa-compat",
   modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
   modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
     headers.set("authorization", `Bearer ${apiKey}`)
     headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
   },
-  modifyBody: (body: Record<string, any>) => {
+  modifyBody: (body: Record<string, any>, workspaceID?: string) => {
     return {
       ...body,
       ...(body.stream ? { stream_options: { include_usage: true } } : {}),
+      ...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
     }
   },
   createBinaryStreamDecoder: () => undefined,

+ 2 - 2
packages/console/app/src/routes/zen/util/provider/openai.ts

@@ -12,13 +12,13 @@ type Usage = {
   total_tokens?: number
 }
 
-export const openaiHelper: ProviderHelper = () => ({
+export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
   format: "openai",
   modifyUrl: (providerApi: string) => providerApi + "/responses",
   modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
     headers.set("authorization", `Bearer ${apiKey}`)
   },
-  modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
+  modifyBody: (body: Record<string, any>) => ({
     ...body,
     ...(workspaceID ? { safety_identifier: workspaceID } : {}),
   }),

+ 8 - 2
packages/console/app/src/routes/zen/util/provider/provider.ts

@@ -33,11 +33,17 @@ export type UsageInfo = {
   cacheWrite1hTokens?: number
 }
 
-export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
+export type ProviderHelper = (input: {
+  reqModel: string
+  providerModel: string
+  adjustCacheUsage?: boolean
+  safetyIdentifier?: string
+  workspaceID?: string
+}) => {
   format: ZenData.Format
   modifyUrl: (providerApi: string, isStream?: boolean) => string
   modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
-  modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
+  modifyBody: (body: Record<string, any>) => Record<string, any>
   createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
   streamSeparator: string
   createUsageParser: () => {

+ 1 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 39 - 0
packages/console/core/script/freeze-workspace.ts

@@ -0,0 +1,39 @@
+import { Billing } from "../src/billing.js"
+import { Database, eq } from "../src/drizzle/index.js"
+import { BillingTable } from "../src/schema/billing.sql.js"
+import { WorkspaceTable } from "../src/schema/workspace.sql.js"
+import { microCentsToCents } from "../src/util/price.js"
+
+// get input from command line
+const workspaceID = process.argv[2]
+
+if (!workspaceID) {
+  console.error("Usage: bun freeze-workspace.ts <workspaceID>")
+  process.exit(1)
+}
+
+// check workspace exists
+const workspace = await Database.use((tx) =>
+  tx
+    .select()
+    .from(WorkspaceTable)
+    .where(eq(WorkspaceTable.id, workspaceID))
+    .then((rows) => rows[0]),
+)
+if (!workspace) {
+  console.error("Error: Workspace not found")
+  process.exit(1)
+}
+
+const billing = await Database.use((tx) =>
+  tx
+    .select()
+    .from(BillingTable)
+    .where(eq(BillingTable.workspaceID, workspaceID))
+    .then((rows) => rows[0]),
+)
+
+const amountInDollars = microCentsToCents(billing.balance) / 100
+await Billing.grantCredit(workspaceID, 0 - amountInDollars)
+
+console.log(`Removed payment of $${amountInDollars.toFixed(2)} from workspace ${workspaceID}`)

+ 63 - 87
packages/console/core/script/lookup-user.ts

@@ -18,8 +18,9 @@ import { ModelTable } from "../src/schema/model.sql.js"
 
 // get input from command line
 const identifier = process.argv[2]
+const verbose = process.argv[process.argv.length - 1] === "-v"
 if (!identifier) {
-  console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
+  console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey> [-v]")
   process.exit(1)
 }
 
@@ -223,93 +224,68 @@ async function printWorkspace(workspaceID: string) {
       ),
   )
 
-  await printTable("28-Day Usage", (tx) =>
-    tx
-      .select({
-        date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
-        requests: sql<number>`COUNT(*)`.as("requests"),
-        inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
-        outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
-        reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
-        cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
-        cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
-        cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
-        cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
-      })
-      .from(UsageTable)
-      .where(
-        and(
-          eq(UsageTable.workspaceID, workspace.id),
-          sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
+  if (verbose) {
+    await printTable("28-Day Usage", (tx) =>
+      tx
+        .select({
+          date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
+          requests: sql<number>`COUNT(*)`.as("requests"),
+          inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
+          outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
+          reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
+          cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
+          cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
+          cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
+          cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
+        })
+        .from(UsageTable)
+        .where(
+          and(
+            eq(UsageTable.workspaceID, workspace.id),
+            sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
+          ),
+        )
+        .groupBy(sql`DATE(${UsageTable.timeCreated})`)
+        .orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
+        .then((rows) => {
+          const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
+          const mapped = rows.map((row) => ({
+            ...row,
+            cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
+          }))
+          if (mapped.length > 0) {
+            mapped.push({
+              date: "TOTAL",
+              requests: null as any,
+              inputTokens: null as any,
+              outputTokens: null as any,
+              reasoningTokens: null as any,
+              cacheReadTokens: null as any,
+              cacheWrite5mTokens: null as any,
+              cacheWrite1hTokens: null as any,
+              cost: `$${(totalCost / 100000000).toFixed(2)}`,
+            })
+          }
+          return mapped
+        }),
+    )
+    await printTable("Disabled Models", (tx) =>
+      tx
+        .select({
+          model: ModelTable.model,
+          timeCreated: ModelTable.timeCreated,
+        })
+        .from(ModelTable)
+        .where(eq(ModelTable.workspaceID, workspace.id))
+        .orderBy(sql`${ModelTable.timeCreated} DESC`)
+        .then((rows) =>
+          rows.map((row) => ({
+            model: row.model,
+            timeCreated: formatDate(row.timeCreated),
+          })),
         ),
-      )
-      .groupBy(sql`DATE(${UsageTable.timeCreated})`)
-      .orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
-      .then((rows) => {
-        const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
-        const mapped = rows.map((row) => ({
-          ...row,
-          cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
-        }))
-        if (mapped.length > 0) {
-          mapped.push({
-            date: "TOTAL",
-            requests: null as any,
-            inputTokens: null as any,
-            outputTokens: null as any,
-            reasoningTokens: null as any,
-            cacheReadTokens: null as any,
-            cacheWrite5mTokens: null as any,
-            cacheWrite1hTokens: null as any,
-            cost: `$${(totalCost / 100000000).toFixed(2)}`,
-          })
-        }
-        return mapped
-      }),
-  )
-  /*
-  await printTable("Usage", (tx) =>
-    tx
-      .select({
-        model: UsageTable.model,
-        provider: UsageTable.provider,
-        inputTokens: UsageTable.inputTokens,
-        outputTokens: UsageTable.outputTokens,
-        reasoningTokens: UsageTable.reasoningTokens,
-        cacheReadTokens: UsageTable.cacheReadTokens,
-        cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
-        cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
-        cost: UsageTable.cost,
-        timeCreated: UsageTable.timeCreated,
-      })
-      .from(UsageTable)
-      .where(eq(UsageTable.workspaceID, workspace.id))
-      .orderBy(sql`${UsageTable.timeCreated} DESC`)
-      .limit(10)
-      .then((rows) =>
-        rows.map((row) => ({
-          ...row,
-          cost: `$${(row.cost / 100000000).toFixed(2)}`,
-        })),
-      ),
-  )
-  await printTable("Disabled Models", (tx) =>
-    tx
-      .select({
-        model: ModelTable.model,
-        timeCreated: ModelTable.timeCreated,
-      })
-      .from(ModelTable)
-      .where(eq(ModelTable.workspaceID, workspace.id))
-      .orderBy(sql`${ModelTable.timeCreated} DESC`)
-      .then((rows) =>
-        rows.map((row) => ({
-          model: row.model,
-          timeCreated: formatDate(row.timeCreated),
-        })),
-      ),
-  )
-        */
+    )
+  }
 }
 
 function formatMicroCents(value: number | null | undefined) {

+ 1 - 1
packages/console/core/script/promote-limits.ts

@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
 const root = path.resolve(process.cwd(), "..", "..", "..")
 
 // read the secret
-const ret = await $`bun sst secret list`.cwd(root).text()
+const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
 const lines = ret.split("\n")
 const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
 if (!value) throw new Error("ZEN_LIMITS not found")

+ 1 - 1
packages/console/core/script/promote-models.ts

@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
 const PARTS = 30
 
 // read the secret
-const ret = await $`bun sst secret list`.cwd(root).text()
+const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
 const lines = ret.split("\n")
 const values = Array.from({ length: PARTS }, (_, i) => {
   const value = lines

+ 2 - 2
packages/console/core/script/update-limits.ts

@@ -6,7 +6,7 @@ import os from "os"
 import { Subscription } from "../src/subscription"
 
 const root = path.resolve(process.cwd(), "..", "..", "..")
-const secrets = await $`bun sst secret list`.cwd(root).text()
+const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
 
 // read value
 const lines = secrets.split("\n")
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
 Subscription.validate(JSON.parse(newValue))
 
 // update the secret
-await $`bun sst secret set ZEN_LIMITS ${newValue}`
+await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)

+ 2 - 2
packages/console/core/script/update-models.ts

@@ -6,7 +6,7 @@ import os from "os"
 import { ZenData } from "../src/model"
 
 const root = path.resolve(process.cwd(), "..", "..", "..")
-const models = await $`bun sst secret list`.cwd(root).text()
+const models = await $`bun sst secret list --stage frank`.cwd(root).text()
 const PARTS = 30
 
 // read the line starting with "ZEN_MODELS"
@@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
 
 const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
 await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
-await $`bun sst secret load ${envFile.name}`.cwd(root)
+await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)

+ 1 - 0
packages/console/core/src/model.ts

@@ -37,6 +37,7 @@ export namespace ZenData {
         disabled: z.boolean().optional(),
         storeModel: z.string().optional(),
         payloadModifier: z.record(z.string(), z.any()).optional(),
+        safetyIdentifier: z.boolean().optional(),
       }),
     ),
   })

+ 4 - 0
packages/console/core/src/util/price.ts

@@ -1,3 +1,7 @@
 export function centsToMicroCents(amount: number) {
   return Math.round(amount * 1000000)
 }
+
+export function microCentsToCents(amount: number) {
+  return Math.round(amount / 1000000)
+}

+ 4 - 4
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-function",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",
@@ -17,9 +17,9 @@
     "@typescript/native-preview": "catalog:"
   },
   "dependencies": {
-    "@ai-sdk/anthropic": "2.0.0",
-    "@ai-sdk/openai": "2.0.2",
-    "@ai-sdk/openai-compatible": "1.0.1",
+    "@ai-sdk/anthropic": "3.0.64",
+    "@ai-sdk/openai": "3.0.48",
+    "@ai-sdk/openai-compatible": "2.0.37",
     "@hono/zod-validator": "catalog:",
     "@opencode-ai/console-core": "workspace:*",
     "@opencode-ai/console-resource": "workspace:*",

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-mail",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",

+ 23 - 0
packages/desktop-electron/electron-builder.config.ts

@@ -1,5 +1,25 @@
+import { execFile } from "node:child_process"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+import { promisify } from "node:util"
+
 import type { Configuration } from "electron-builder"
 
+const execFileAsync = promisify(execFile)
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
+const signScript = path.join(rootDir, "script", "sign-windows.ps1")
+
+async function signWindows(configuration: { path: string }) {
+  if (process.platform !== "win32") return
+  if (process.env.GITHUB_ACTIONS !== "true") return
+
+  await execFileAsync(
+    "pwsh",
+    ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path],
+    { cwd: rootDir },
+  )
+}
+
 const channel = (() => {
   const raw = process.env.OPENCODE_CHANNEL
   if (raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,6 +64,9 @@ const getBase = (): Configuration => ({
   },
   win: {
     icon: `resources/icons/icon.ico`,
+    signtoolOptions: {
+      sign: signWindows,
+    },
     target: ["nsis"],
   },
   nsis: {

+ 3 - 0
packages/desktop-electron/icons/README.md

@@ -9,3 +9,6 @@ Here's the process I've been using to create icons:
 
 The Image2Icon step is necessary as the `icon.icns` generated by `app-icon.png` does not apply the shadow/padding expected by macOS,
 so app icons appear larger than expected.
+
+For unpackaged Electron on macOS, `app.dock.setIcon()` should use a PNG. Keep `dock.png` in each channel folder synced with the
+extracted `[email protected]` from that channel's `icon.icns` so the dev Dock icon matches the packaged app inset.

BIN
packages/desktop-electron/icons/beta/dock.png


BIN
packages/desktop-electron/icons/dev/dock.png


BIN
packages/desktop-electron/icons/prod/dock.png


+ 1 - 1
packages/desktop-electron/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/desktop-electron",
   "private": true,
-  "version": "1.3.3",
+  "version": "1.3.13",
   "type": "module",
   "license": "MIT",
   "homepage": "https://opencode.ai",

+ 2 - 1
packages/desktop-electron/scripts/prepare.ts

@@ -13,11 +13,12 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
 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 ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
+await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
 
 await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
 

+ 3 - 0
packages/desktop-electron/scripts/utils.ts

@@ -63,6 +63,9 @@ export async function copyBinaryToSidecarFolder(source: string) {
   await $`mkdir -p ${dir}`
   const dest = windowsify(`${dir}/opencode-cli`)
   await $`cp ${source} ${dest}`
+  if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
+    await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
+  }
   if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
 
   console.log(`Copied ${source} to ${dest}`)

+ 10 - 7
packages/desktop-electron/src/main/cli.ts

@@ -9,6 +9,7 @@ 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"
@@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
   const base = Object.fromEntries(
     Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
   )
-  const envs = {
+  const env = {
     ...base,
     OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
     OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
@@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
     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)
+  const { cmd, cmdArgs } = buildCommand(args, envs, shell)
   console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
   const child = spawn(cmd, cmdArgs, {
     env: envs,
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
   return false
 }
 
-function buildCommand(args: string, env: Record<string, string>) {
+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()
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
   }
 
   const sidecar = getSidecarPath()
-  const shell = process.env.SHELL || "/bin/sh"
-  const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
-  console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
-  return { cmd: shell, cmdArgs: ["-l", "-c", line] }
+  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>) {

+ 43 - 0
packages/desktop-electron/src/main/shell-env.test.ts

@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+
+import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
+
+describe("shell env", () => {
+  test("parseShellEnv supports null-delimited pairs", () => {
+    const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
+
+    expect(env.PATH).toBe("/usr/bin:/bin")
+    expect(env.FOO).toBe("bar=baz")
+  })
+
+  test("parseShellEnv ignores invalid entries", () => {
+    const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
+
+    expect(Object.keys(env).length).toBe(1)
+    expect(env.OK).toBe("1")
+  })
+
+  test("mergeShellEnv keeps explicit overrides", () => {
+    const env = mergeShellEnv(
+      {
+        PATH: "/shell/path",
+        HOME: "/tmp/home",
+      },
+      {
+        PATH: "/desktop/path",
+        OPENCODE_CLIENT: "desktop",
+      },
+    )
+
+    expect(env.PATH).toBe("/desktop/path")
+    expect(env.HOME).toBe("/tmp/home")
+    expect(env.OPENCODE_CLIENT).toBe("desktop")
+  })
+
+  test("isNushell handles path and binary name", () => {
+    expect(isNushell("nu")).toBe(true)
+    expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
+    expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
+    expect(isNushell("/bin/zsh")).toBe(false)
+  })
+})

+ 88 - 0
packages/desktop-electron/src/main/shell-env.ts

@@ -0,0 +1,88 @@
+import { spawnSync } from "node:child_process"
+import { basename } from "node:path"
+
+const SHELL_ENV_TIMEOUT = 5_000
+
+type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
+
+export function getUserShell() {
+  return process.env.SHELL || "/bin/sh"
+}
+
+export function parseShellEnv(out: Buffer) {
+  const env: Record<string, string> = {}
+  for (const line of out.toString("utf8").split("\0")) {
+    if (!line) continue
+    const ix = line.indexOf("=")
+    if (ix <= 0) continue
+    env[line.slice(0, ix)] = line.slice(ix + 1)
+  }
+  return env
+}
+
+function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
+  const out = spawnSync(shell, [mode, "-c", "env -0"], {
+    stdio: ["ignore", "pipe", "ignore"],
+    timeout: SHELL_ENV_TIMEOUT,
+    windowsHide: true,
+  })
+
+  const err = out.error as NodeJS.ErrnoException | undefined
+  if (err) {
+    if (err.code === "ETIMEDOUT") return { type: "Timeout" }
+    console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
+    return { type: "Unavailable" }
+  }
+
+  if (out.status !== 0) {
+    console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
+    return { type: "Unavailable" }
+  }
+
+  const env = parseShellEnv(out.stdout)
+  if (Object.keys(env).length === 0) {
+    console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
+    return { type: "Unavailable" }
+  }
+
+  return { type: "Loaded", value: env }
+}
+
+export function isNushell(shell: string) {
+  const name = basename(shell).toLowerCase()
+  const raw = shell.toLowerCase()
+  return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
+}
+
+export function loadShellEnv(shell: string) {
+  if (isNushell(shell)) {
+    console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
+    return null
+  }
+
+  const interactive = probeShellEnv(shell, "-il")
+  if (interactive.type === "Loaded") {
+    console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
+    return interactive.value
+  }
+  if (interactive.type === "Timeout") {
+    console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
+    return null
+  }
+
+  const login = probeShellEnv(shell, "-l")
+  if (login.type === "Loaded") {
+    console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
+    return login.value
+  }
+
+  console.warn(`[cli] Falling back to app environment: ${shell}`)
+  return null
+}
+
+export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
+  return {
+    ...(shell || {}),
+    ...env,
+  }
+}

+ 2 - 1
packages/desktop-electron/src/main/windows.ts

@@ -50,7 +50,8 @@ export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> =
 
 export function setDockIcon() {
   if (process.platform !== "darwin") return
-  app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "[email protected]")))
+  const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
+  if (!icon.isEmpty()) app.dock?.setIcon(icon)
 }
 
 export function createMainWindow(globals: Globals) {

+ 1 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/desktop",
   "private": true,
-  "version": "1.3.3",
+  "version": "1.3.13",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 2 - 1
packages/desktop/scripts/prepare.ts

@@ -10,10 +10,11 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
 console.log(`Updated package.json version to ${Script.version}`)
 
 const sidecarConfig = getCurrentSidecar()
+const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
 
 const dir = "src-tauri/target/opencode-binaries"
 
 await $`mkdir -p ${dir}`
-await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
+await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
 
 await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))

+ 3 - 0
packages/desktop/scripts/utils.ts

@@ -48,6 +48,9 @@ export async function copyBinaryToSidecarFolder(source: string, target = RUST_TA
   await $`mkdir -p src-tauri/sidecars`
   const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
   await $`cp ${source} ${dest}`
+  if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
+    await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
+  }
 
   console.log(`Copied ${source} to ${dest}`)
 }

+ 4 - 0
packages/desktop/src-tauri/tauri.beta.conf.json

@@ -12,6 +12,10 @@
       "icons/beta/icon.ico"
     ],
     "windows": {
+      "signCommand": {
+        "cmd": "powershell",
+        "args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
+      },
       "nsis": {
         "installerIcon": "icons/beta/icon.ico"
       }

+ 4 - 0
packages/desktop/src-tauri/tauri.conf.json

@@ -45,6 +45,10 @@
       "entitlements": "./entitlements.plist"
     },
     "windows": {
+      "signCommand": {
+        "cmd": "powershell",
+        "args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
+      },
       "nsis": {
         "installerIcon": "icons/dev/icon.ico",
         "headerImage": "assets/nsis-header.bmp",

+ 4 - 0
packages/desktop/src-tauri/tauri.prod.conf.json

@@ -12,6 +12,10 @@
       "icons/prod/icon.ico"
     ],
     "windows": {
+      "signCommand": {
+        "cmd": "powershell",
+        "args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
+      },
       "nsis": {
         "installerIcon": "icons/prod/icon.ico"
       }

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/enterprise",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 6 - 6
packages/extensions/zed/extension.toml

@@ -1,7 +1,7 @@
 id = "opencode"
 name = "OpenCode"
 description = "The open source coding agent."
-version = "1.3.3"
+version = "1.3.13"
 schema_version = 1
 authors = ["Anomaly"]
 repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
 icon = "./icons/opencode.svg"
 
 [agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
 cmd = "./opencode.exe"
 args = ["acp"]

+ 1 - 1
packages/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/function",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 28 - 26
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.3.3",
+  "version": "1.3.13",
   "name": "opencode",
   "type": "module",
   "license": "MIT",
@@ -10,6 +10,7 @@
     "typecheck": "tsgo --noEmit",
     "test": "bun test --timeout 30000",
     "build": "bun run script/build.ts",
+    "upgrade-opentui": "bun run script/upgrade-opentui.ts",
     "dev": "bun run --conditions=browser ./src/index.ts",
     "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
     "clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
@@ -69,25 +70,25 @@
     "@actions/core": "1.11.1",
     "@actions/github": "6.0.1",
     "@agentclientprotocol/sdk": "0.14.1",
-    "@ai-sdk/amazon-bedrock": "3.0.82",
-    "@ai-sdk/anthropic": "2.0.65",
-    "@ai-sdk/azure": "2.0.91",
-    "@ai-sdk/cerebras": "1.0.36",
-    "@ai-sdk/cohere": "2.0.22",
-    "@ai-sdk/deepinfra": "1.0.36",
-    "@ai-sdk/gateway": "2.0.30",
-    "@ai-sdk/google": "2.0.54",
-    "@ai-sdk/google-vertex": "3.0.106",
-    "@ai-sdk/groq": "2.0.34",
-    "@ai-sdk/mistral": "2.0.27",
-    "@ai-sdk/openai": "2.0.89",
-    "@ai-sdk/openai-compatible": "1.0.32",
-    "@ai-sdk/perplexity": "2.0.23",
-    "@ai-sdk/provider": "2.0.1",
-    "@ai-sdk/provider-utils": "3.0.21",
-    "@ai-sdk/togetherai": "1.0.34",
-    "@ai-sdk/vercel": "1.0.33",
-    "@ai-sdk/xai": "2.0.51",
+    "@ai-sdk/amazon-bedrock": "4.0.83",
+    "@ai-sdk/anthropic": "3.0.64",
+    "@ai-sdk/azure": "3.0.49",
+    "@ai-sdk/cerebras": "2.0.41",
+    "@ai-sdk/cohere": "3.0.27",
+    "@ai-sdk/deepinfra": "2.0.41",
+    "@ai-sdk/gateway": "3.0.80",
+    "@ai-sdk/google": "3.0.53",
+    "@ai-sdk/google-vertex": "4.0.95",
+    "@ai-sdk/groq": "3.0.31",
+    "@ai-sdk/mistral": "3.0.27",
+    "@ai-sdk/openai": "3.0.48",
+    "@ai-sdk/openai-compatible": "2.0.37",
+    "@ai-sdk/perplexity": "3.0.26",
+    "@ai-sdk/provider": "3.0.8",
+    "@ai-sdk/provider-utils": "4.0.21",
+    "@ai-sdk/togetherai": "2.0.41",
+    "@ai-sdk/vercel": "2.0.39",
+    "@ai-sdk/xai": "3.0.75",
     "@aws-sdk/credential-providers": "3.993.0",
     "@clack/prompts": "1.0.0-alpha.1",
     "@effect/platform-node": "catalog:",
@@ -102,9 +103,9 @@
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
-    "@openrouter/ai-sdk-provider": "1.5.4",
-    "@opentui/core": "0.1.90",
-    "@opentui/solid": "0.1.90",
+    "@openrouter/ai-sdk-provider": "2.3.3",
+    "@opentui/core": "0.1.95",
+    "@opentui/solid": "0.1.95",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",
@@ -112,7 +113,7 @@
     "@standard-schema/spec": "1.0.0",
     "@zip.js/zip.js": "2.7.62",
     "ai": "catalog:",
-    "ai-gateway-provider": "2.3.1",
+    "ai-gateway-provider": "3.1.2",
     "bonjour-service": "1.3.0",
     "bun-pty": "0.4.8",
     "chokidar": "4.0.3",
@@ -123,7 +124,7 @@
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "5.3.3",
+    "gitlab-ai-provider": "6.0.0",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",
@@ -134,7 +135,7 @@
     "mime-types": "3.0.2",
     "minimatch": "10.0.3",
     "open": "10.1.2",
-    "opencode-gitlab-auth": "2.0.0",
+    "opencode-gitlab-auth": "2.0.1",
     "opencode-poe-auth": "0.0.1",
     "opentui-spinner": "0.0.6",
     "partial-json": "0.1.7",
@@ -143,6 +144,7 @@
     "solid-js": "catalog:",
     "strip-ansi": "7.1.2",
     "tree-sitter-bash": "0.25.0",
+    "tree-sitter-powershell": "0.25.10",
     "turndown": "7.2.0",
     "ulid": "catalog:",
     "vscode-jsonrpc": "8.2.1",

+ 15 - 0
packages/opencode/script/seed-e2e.ts

@@ -2,6 +2,7 @@ const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
 const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
 const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
 const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
+const requirePaid = process.env.OPENCODE_E2E_REQUIRE_PAID === "true"
 const parts = model.split("/")
 const providerID = parts[0] ?? "opencode"
 const modelID = parts[1] ?? "gpt-5-nano"
@@ -11,6 +12,7 @@ const seed = async () => {
   const { Instance } = await import("../src/project/instance")
   const { InstanceBootstrap } = await import("../src/project/bootstrap")
   const { Config } = await import("../src/config/config")
+  const { Provider } = await import("../src/provider/provider")
   const { Session } = await import("../src/session")
   const { MessageID, PartID } = await import("../src/session/schema")
   const { Project } = await import("../src/project/project")
@@ -25,6 +27,19 @@ const seed = async () => {
         await Config.waitForDependencies()
         await ToolRegistry.ids()
 
+        if (requirePaid && providerID === "opencode" && !process.env.OPENCODE_API_KEY) {
+          throw new Error("OPENCODE_API_KEY is required when OPENCODE_E2E_REQUIRE_PAID=true")
+        }
+
+        const info = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID))
+        if (requirePaid) {
+          const paid =
+            info.cost.input > 0 || info.cost.output > 0 || info.cost.cache.read > 0 || info.cost.cache.write > 0
+          if (!paid) {
+            throw new Error(`OPENCODE_E2E_MODEL must resolve to a paid model: ${providerID}/${modelID}`)
+          }
+        }
+
         const session = await Session.create({ title })
         const messageID = MessageID.ascending()
         const partID = PartID.ascending()

+ 64 - 0
packages/opencode/script/upgrade-opentui.ts

@@ -0,0 +1,64 @@
+#!/usr/bin/env bun
+
+import path from "node:path"
+
+const raw = process.argv[2]
+if (!raw) {
+  console.error("Usage: bun run script/upgrade-opentui.ts <version>")
+  process.exit(1)
+}
+
+const ver = raw.replace(/^v/, "")
+const root = path.resolve(import.meta.dir, "../../..")
+const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
+const keys = ["@opentui/core", "@opentui/solid"] as const
+
+const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
+  (file) => !file.split("/").some((part) => skip.has(part)),
+)
+
+const set = (cur: string) => {
+  if (cur.startsWith(">=")) return `>=${ver}`
+  if (cur.startsWith("^")) return `^${ver}`
+  if (cur.startsWith("~")) return `~${ver}`
+  return ver
+}
+
+const edit = (obj: unknown) => {
+  if (!obj || typeof obj !== "object") return false
+  const map = obj as Record<string, unknown>
+  return keys
+    .map((key) => {
+      const cur = map[key]
+      if (typeof cur !== "string") return false
+      const next = set(cur)
+      if (next === cur) return false
+      map[key] = next
+      return true
+    })
+    .some(Boolean)
+}
+
+const out = (
+  await Promise.all(
+    files.map(async (rel) => {
+      const file = path.join(root, rel)
+      const txt = await Bun.file(file).text()
+      const json = JSON.parse(txt)
+      const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean)
+      if (!hit) return null
+      await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
+      return rel
+    }),
+  )
+).filter((item): item is string => item !== null)
+
+if (out.length === 0) {
+  console.log("No opentui deps found")
+  process.exit(0)
+}
+
+console.log(`Updated opentui to ${ver} in:`)
+for (const file of out) {
+  console.log(`- ${file}`)
+}

+ 78 - 6
packages/opencode/specs/effect-migration.md

@@ -210,10 +210,82 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
 - [x] `Vcs` — `project/vcs.ts`
 - [x] `Worktree` — `worktree/index.ts`
 
-Still open and likely worth migrating:
+- [x] `Session` — `session/index.ts`
+- [x] `SessionProcessor` — `session/processor.ts`
+- [x] `SessionPrompt` — `session/prompt.ts`
+- [x] `SessionCompaction` — `session/compaction.ts`
+- [x] `Provider` — `provider/provider.ts`
 
-- [ ] `Session`
-- [ ] `SessionProcessor`
-- [ ] `SessionPrompt`
-- [ ] `SessionCompaction`
-- [ ] `Provider`
+Still open:
+
+- [ ] `SessionSummary` — `session/summary.ts`
+- [ ] `SessionTodo` — `session/todo.ts`
+- [ ] `SessionRevert` — `session/revert.ts`
+- [ ] `Instruction` — `session/instruction.ts`
+- [ ] `ShareNext` — `share/share-next.ts`
+- [ ] `SyncEvent` — `sync/index.ts`
+- [ ] `Storage` — `storage/storage.ts`
+- [ ] `Workspace` — `control-plane/workspace.ts`
+
+## Tool interface → Effect
+
+Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
+
+1. Migrate each tool to return Effects
+2. Update `Tool.define()` factory to work with Effects
+3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
+
+Individual tools, ordered by value:
+
+- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
+- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
+- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
+- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
+- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
+- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
+- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
+- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
+- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
+- [ ] `task.ts` — MEDIUM: task state management
+- [ ] `glob.ts` — LOW: simple async generator
+- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
+- [ ] `skill.ts` — LOW: skill tool adapter
+- [ ] `plan.ts` — LOW: plan file operations
+
+## Effect service adoption in already-migrated code
+
+Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
+
+### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
+
+- [ ] `file/index.ts` — 11 calls (the File service itself)
+- [ ] `config/config.ts` — 7 calls
+- [ ] `auth/index.ts` — 3 calls
+- [ ] `skill/index.ts` — 3 calls
+- [ ] `file/time.ts` — 1 call
+
+### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
+
+- [ ] `format/index.ts` — 1 call
+
+## Filesystem consolidation
+
+`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
+
+Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
+
+Current raw fs users that will convert during tool migration:
+
+- `tool/read.ts` — fs.createReadStream, readline
+- `tool/apply_patch.ts` — fs/promises
+- `tool/bash.ts` — fs/promises
+- `file/ripgrep.ts` — fs/promises
+- `storage/storage.ts` — fs/promises
+- `patch/index.ts` — fs, fs/promises
+
+## Primitives & utilities
+
+- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
+- [ ] `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/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code

+ 50 - 17
packages/opencode/specs/tui-plugins.md

@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
 - Author package entrypoint is `@opencode-ai/plugin/tui`.
 - Internal plugins load inside the CLI app the same way external TUI plugins do.
 - Package plugins can be installed from CLI or TUI.
+- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
+- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
 
 ## TUI config
 
@@ -27,6 +29,7 @@ Example:
 - `plugin` entries can be either a string spec or `[spec, options]`.
 - Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
 - Relative path specs are resolved relative to the config file that declared them.
+- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
 - Duplicate npm plugins are deduped by package name; higher-precedence config wins.
 - Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
 - `plugin_enabled` is keyed by plugin id, not by plugin spec.
@@ -46,7 +49,7 @@ Minimal module shape:
 
 ```tsx
 /** @jsxImportSource @opentui/solid */
-import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
 
 const tui: TuiPlugin = async (api, options, meta) => {
   api.command.register(() => [
@@ -69,25 +72,39 @@ const tui: TuiPlugin = async (api, options, meta) => {
   ])
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id: "acme.demo",
   tui,
 }
+
+export default plugin
 ```
 
 - Loader only reads the module default export object. Named exports are ignored.
-- TUI shape is `default export { id?, tui }`.
+- TUI shape is `default export { id?, tui }`; including `server` is rejected.
+- A single module cannot export both `server` and `tui`.
 - `tui` signature is `(api, options, meta) => Promise<void>`.
-- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
+- If package `exports` contains `./tui`, the loader resolves that entrypoint.
+- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
+- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
+- `package.json` `main` is only used for server plugin entrypoint resolution.
+- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
+- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
 - File/path plugins must export a non-empty `id`.
 - npm plugins may omit `id`; package `name` is used.
 - Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
-- If a path spec points at a directory, that directory must have `package.json` with `main`.
+- If a path spec points at a directory, server loading can use `package.json` `main`.
+- TUI path loading never uses `package.json` `main`.
+- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
+- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
 - There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
 
 ## Package manifest and install
 
-Package manifest is read from `package.json` field `oc-plugin`.
+Install target detection is inferred from `package.json` entrypoints:
+
+- `server` target when `exports["./server"]` exists or `main` is set.
+- `tui` target when `exports["./tui"]` exists.
 
 Example:
 
@@ -95,14 +112,20 @@ Example:
 {
   "name": "@acme/opencode-plugin",
   "type": "module",
-  "main": "./dist/index.js",
+  "main": "./dist/server.js",
+  "exports": {
+    "./server": {
+      "import": "./dist/server.js",
+      "config": { "custom": true }
+    },
+    "./tui": {
+      "import": "./dist/tui.js",
+      "config": { "compact": true }
+    }
+  },
   "engines": {
     "opencode": "^1.0.0"
-  },
-  "oc-plugin": [
-    ["server", { "custom": true }],
-    ["tui", { "compact": true }]
-  ]
+  }
 }
 ```
 
@@ -131,12 +154,19 @@ npm plugins can declare a version compatibility range in `package.json` using th
 - Local installs resolve target dir inside `patchPluginConfig`.
 - For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
 - Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
-- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
+- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
 - `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
+- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
+- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
+- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
+- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
 - Without `--force`, an already-configured npm package name is a no-op.
 - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
+- Explicit npm specs with a version suffix (for example `[email protected]`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
+- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
 - Tuple targets in `oc-plugin` provide default options written into config.
 - A package can target `server`, `tui`, or both.
+- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
 - There is no uninstall, list, or update CLI command for external plugins.
 - Local file plugins are configured directly in `tui.json`.
 
@@ -156,7 +186,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
 - `api.app.version`
 - `api.command.register(cb)` / `api.command.trigger(value)`
 - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
-- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
+- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
 - `api.keybind.match`, `print`, `create`
 - `api.tuiConfig`
 - `api.kv.get`, `set`, `ready`
@@ -202,6 +232,7 @@ Command behavior:
 
 - `ui.Dialog` is the base dialog wrapper.
 - `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
+- `ui.Prompt` renders the same prompt component used by the host app.
 - `ui.toast(...)` shows a toast.
 - `ui.dialog` exposes the host dialog stack:
   - `replace(render, onClose?)`
@@ -258,7 +289,9 @@ Theme install behavior:
 
 - Relative theme paths are resolved from the plugin root.
 - Theme name is the JSON basename.
-- Install is skipped if that theme name already exists.
+- First install writes only when the destination file is missing.
+- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
+- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
 - Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
 - Global plugins persist installed themes under the global `themes` dir.
 - Invalid or unreadable theme files are ignored.
@@ -269,6 +302,7 @@ Current host slot names:
 
 - `app`
 - `home_logo`
+- `home_prompt` with props `{ workspace_id? }`
 - `home_bottom`
 - `sidebar_title` with props `{ session_id, title, share_url? }`
 - `sidebar_content` with props `{ session_id }`
@@ -281,7 +315,7 @@ Slot notes:
 - `api.slots.register(plugin)` does not return an unregister function.
 - Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
 - Plugin-provided `id` is not allowed.
-- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
+- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
 - Plugins cannot define new slot names in this branch.
 
 ### Plugin control and lifecycle
@@ -297,7 +331,6 @@ Slot notes:
 - `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
 - `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
 - `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
-- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
 - If activation fails, the plugin can remain `enabled=true` and `active=false`.
 - `api.lifecycle.signal` is aborted before cleanup runs.
 - `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor