diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 26cef37e..414a56f0 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -24,13 +24,13 @@ jobs:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.app-token.outputs.token }}
- days-before-stale: 60
+ days-before-stale: 28
days-before-close: 7
stale-issue-label: stale
stale-pr-label: stale
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
- any activity in the last 60 days. It will be closed in 7 days if no
+ any activity in the last 28 days. It will be closed in 7 days if no
further activity occurs.
close-issue-message: >
This issue has been automatically closed because it has been stale for
@@ -38,7 +38,7 @@ jobs:
reopen the issue.
stale-pr-message: >
This pull request has been automatically marked as stale because it has
- not had any activity in the last 60 days. It will be closed in 7 days
+ not had any activity in the last 28 days. It will be closed in 7 days
if no further activity occurs.
close-pr-message: >
This pull request has been automatically closed because it has been
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65659752..38d653c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,41 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [1.20.0] - 2026-08-23
+
+- **Session: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17).
+- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context.
+- **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it.
+- Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace.
+- Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise.
+- Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server.
+- Skills catalog: browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository.
+- Diff: the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not.
+- Dictation: speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words.
+- Settings: the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it.
+- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults.
+- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately.
+- Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them.
+- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed.
+- Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx).
+- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings.
+- Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr).
+- Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up.
+- Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident.
+- Providers: expanded support for custom providers.
+- Small Model: summaries, goal audits, commit messages, and walkthroughs now support more providers.
+- Git: generated commit messages now match the repository's recent commit style and language.
+- Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout.
+- Sidebar: switch between the full project list and a focused view of one project. Sessions created outside OpenChamber now also appear in the sidebar and Recent list without a page refresh (thanks to @tomzx).
- Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
+- Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes.
+- Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off.
+- Chat: long user messages can be expanded even when their final layout finishes after they first appear.
+- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it.
+- Usage: Z.ai credit limits now appear alongside its other quota windows.
+- Git: pull-request checks in Work status stay current as their status changes.
+- UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer).
+- Desktop/Windows: the close button now aligns correctly with the rest of the window chrome.
## [1.19.0] - 2026-08-19
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9e6ba882..7db3d8bb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -204,7 +204,7 @@ state why it remains valid. If there is genuinely no user-visible change, say
so and provide a concrete reason; deleting the evidence section is not an
exemption.
-### Review Enforcement
+### Review enforcement
The automated reviewer performs one unified review of correctness, repository
guidance compliance, pull request quality, and evidence. It independently
@@ -238,6 +238,16 @@ verify a trustworthy result, in which case it applies `review:automation-failed`
Each completed review creates a new comment tied to its reviewed HEAD so the
conversation remains chronological. Previous review comments are not rewritten.
+### Keeping PRs active
+
+Stale PRs add review load and make it hard to tell what's still being worked on, so the stale bot keeps the open list current. A PR with no activity for 28 days is automatically labeled `stale`, and closed 7 days later if it stays inactive. To keep a PR open:
+
+- Push updates or respond to review feedback
+- Leave a comment if you're waiting on a reviewer
+- Add the `pinned`, `security`, or `help wanted` label to exempt a long-running PR from the stale bot
+
+Reopening a closed PR is fine if it becomes relevant again.
+
## Project Structure
```
diff --git a/bun.lock b/bun.lock
index 753e10c4..2b7a26c2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -30,7 +30,7 @@
"@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
- "@opencode-ai/sdk": "1.18.18",
+ "@opencode-ai/sdk": "1.18.21",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -168,7 +168,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
- "@opencode-ai/sdk": "1.18.18",
+ "@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0",
@@ -241,7 +241,7 @@
"version": "1.19.0",
"dependencies": {
"@openchamber/ui": "workspace:*",
- "@opencode-ai/sdk": "1.18.18",
+ "@opencode-ai/sdk": "1.18.21",
"adm-zip": "^0.6.0",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -268,7 +268,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
- "@opencode-ai/sdk": "1.18.18",
+ "@opencode-ai/sdk": "1.18.21",
"@simplewebauthn/server": "13.3.1",
"bun-pty": "^0.4.5",
"compression": "^1.8.1",
@@ -1003,7 +1003,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
- "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="],
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
diff --git a/package.json b/package.json
index d8a591f0..b4143c6a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openchamber-monorepo",
- "version": "1.19.0",
+ "version": "1.20.0",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true,
"type": "module",
@@ -116,7 +116,7 @@
"@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
- "@opencode-ai/sdk": "1.18.18",
+ "@opencode-ai/sdk": "1.18.21",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx
index 42f5972b..38b24051 100644
--- a/packages/docs/content/docs/de/integrations.mdx
+++ b/packages/docs/content/docs/de/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Integrationen
-description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider.
+description: Nutze dein Claude- oder Cursor-Abo als Provider.
---
# Integrationen
Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**.
+> **Experimentelle Funktion.** Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko.
+
Verfügbare Integrationen:
- **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys
-- **Command Code** — dein Command-Code-Plan
- **Cursor** — die Modell-Limits deines Cursor-Plans
## Integration installieren
@@ -29,19 +30,10 @@ Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne se
1. Installiere die Integration (siehe oben).
2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an.
-Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Command Code und Cursor brauchen ihre CLIs nicht.
+Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht.
**Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko.
-## Command Code
-
-Command Code nutzt deinen Command-Code-Plan.
-
-1. Installiere die Integration (siehe oben).
-2. Wähle **Set up** — es öffnet sich eine Browserseite. Genehmige den Zugriff und kehre zu OpenChamber zurück.
-
-Auf einem Headless-Server oder in CI setze stattdessen die Umgebungsvariable `COMMAND_CODE_API_KEY`, anstatt dich im Browser anzumelden.
-
## Cursor
Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar.
diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx
index 206e40d8..17582762 100644
--- a/packages/docs/content/docs/es/integrations.mdx
+++ b/packages/docs/content/docs/es/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Integraciones
-description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor.
+description: Usa tu suscripción de Claude o Cursor como proveedor.
---
# Integraciones
Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**.
+> **Función experimental.** Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad.
+
Integraciones disponibles:
- **Claude Code** — tu plan Claude Pro o Max, sin claves de API
-- **Command Code** — tu plan de Command Code
- **Cursor** — los límites de modelos de tu plan de Cursor
## Instalar una integración
@@ -29,19 +30,10 @@ Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de
1. Instala la integración (arriba).
2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión.
-Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Command Code y Cursor no requieren sus CLIs.
+Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Cursor no requiere su CLI.
**Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados.
-## Command Code
-
-Command Code usa tu plan de Command Code.
-
-1. Instala la integración (arriba).
-2. Elige **Set up** — se abre una página en el navegador. Autoriza el acceso y vuelve a OpenChamber.
-
-En una máquina sin interfaz gráfica o en CI, define la variable de entorno `COMMAND_CODE_API_KEY` en lugar de iniciar sesión en el navegador.
-
## Cursor
Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor.
diff --git a/packages/docs/content/docs/es/providers.mdx b/packages/docs/content/docs/es/providers.mdx
index 24e5b94f..65526b23 100644
--- a/packages/docs/content/docs/es/providers.mdx
+++ b/packages/docs/content/docs/es/providers.mdx
@@ -45,6 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a
## Relacionado
-- [Integraciones](/es/integrations/) — usa una suscripción de Claude, Command Code o Cursor como proveedor
+- [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor
- [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes
- [Uso y cuotas](/es/usage/) — controla cuánto has consumido
diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx
index df347a34..2628a870 100644
--- a/packages/docs/content/docs/fr/integrations.mdx
+++ b/packages/docs/content/docs/fr/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Intégrations
-description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournisseur.
+description: Utilise ton abonnement Claude ou Cursor comme fournisseur.
---
# Intégrations
Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**.
+> **Fonctionnalité expérimentale.** Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilise les intégrations à tes risques.
+
Intégrations disponibles :
- **Claude Code** — ton plan Claude Pro ou Max, sans clés API
-- **Command Code** — ton plan Command Code
- **Cursor** — les limites de modèles de ton plan Cursor
## Installer une intégration
@@ -29,19 +30,10 @@ Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans applic
1. Installe l'intégration (ci-dessus).
2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter.
-Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Command Code et Cursor n'exigent pas leurs CLIs.
+Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Cursor n'exige pas sa CLI.
**Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés.
-## Command Code
-
-Command Code utilise ton plan Command Code.
-
-1. Installe l'intégration (ci-dessus).
-2. Choisis **Set up** — une page s'ouvre dans le navigateur. Autorise l'accès, puis reviens dans OpenChamber.
-
-Sur une machine sans interface graphique ou en CI, définis la variable d'environnement `COMMAND_CODE_API_KEY` au lieu de te connecter via le navigateur.
-
## Cursor
Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor.
diff --git a/packages/docs/content/docs/fr/providers.mdx b/packages/docs/content/docs/fr/providers.mdx
index d79633d8..a3ce0149 100644
--- a/packages/docs/content/docs/fr/providers.mdx
+++ b/packages/docs/content/docs/fr/providers.mdx
@@ -45,6 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e
## Pages liées
-- [Intégrations](/integrations/) — utiliser un abonnement Claude, Command Code ou Cursor comme fournisseur
+- [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur
- [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents
- [Utilisation et quotas](/usage/) — suivre votre consommation
diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx
index 14aee1f1..8aef2d38 100644
--- a/packages/docs/content/docs/integrations.mdx
+++ b/packages/docs/content/docs/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Integrations
-description: Use your Claude, Command Code, or Cursor subscription as a provider.
+description: Use your Claude or Cursor subscription as a provider.
---
# Integrations
An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**.
+> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk.
+
Available integrations:
- **Claude Code** — your Claude Pro or Max plan, no API keys
-- **Command Code** — your Command Code plan
- **Cursor** — the model limits of your Cursor plan
## Install an integration
@@ -29,19 +30,10 @@ Claude Code uses your Claude Pro or Max plan — no API keys and no separate Cla
1. Install the integration (above).
2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in.
-Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Command Code and Cursor do not require their CLIs.
+Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI.
**How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds.
-## Command Code
-
-Command Code uses your Command Code plan.
-
-1. Install the integration (above).
-2. Choose **Set up** — a browser page opens. Approve access, then return to OpenChamber.
-
-On a headless machine or in CI, set the `COMMAND_CODE_API_KEY` environment variable instead of signing in in the browser.
-
## Cursor
Cursor makes the models included in your Cursor plan available in OpenChamber.
diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx
index 02fa8e36..b13ac0cd 100644
--- a/packages/docs/content/docs/ja/integrations.mdx
+++ b/packages/docs/content/docs/ja/integrations.mdx
@@ -1,16 +1,17 @@
---
title: 統合機能
-description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。
+description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。
---
# 統合機能
統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。
+> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。
+
利用できる統合機能:
- **Claude Code** — Claude Pro または Max プラン、API キー不要
-- **Command Code** — Command Code のプラン
- **Cursor** — Cursor プランのモデル利用枠
## 統合機能をインストールする
@@ -29,19 +30,10 @@ Claude Code は Claude Pro または Max プランを使います — API キー
1. 統合機能をインストールします(上記)。
2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。
-Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Command Code と Cursor は CLI を必要としません。
+Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。
**Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。
-## Command Code
-
-Command Code は Command Code のプランを使います。
-
-1. 統合機能をインストールします(上記)。
-2. **Set up** を選ぶとブラウザーでページが開きます。アクセスを許可して OpenChamber に戻ります。
-
-画面のないサーバーや CI では、ブラウザーでサインインする代わりに環境変数 `COMMAND_CODE_API_KEY` を設定してください。
-
## Cursor
Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。
diff --git a/packages/docs/content/docs/ja/providers.mdx b/packages/docs/content/docs/ja/providers.mdx
index 62f1c663..7e9c0525 100644
--- a/packages/docs/content/docs/ja/providers.mdx
+++ b/packages/docs/content/docs/ja/providers.mdx
@@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ
## 関連
-- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う
+- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
- [使用量とクォータ](/usage/) — 使った量を追跡する
diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx
index f2589d50..0663879e 100644
--- a/packages/docs/content/docs/ko/integrations.mdx
+++ b/packages/docs/content/docs/ko/integrations.mdx
@@ -1,16 +1,17 @@
---
title: 통합 기능
-description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요.
+description: Claude 또는 Cursor 구독을 공급자로 사용하세요.
---
# 통합 기능
통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다.
+> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.
+
사용 가능한 통합 기능:
- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요
-- **Command Code** — Command Code 플랜
- **Cursor** — Cursor 플랜의 모델 한도
## 통합 기능 설치
@@ -29,19 +30,10 @@ Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도
1. 통합 기능을 설치합니다(위 참고).
2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다.
-Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 Cursor는 CLI가 필요 없습니다.
+Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다.
**Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다.
-## Command Code
-
-Command Code는 Command Code 플랜을 사용합니다.
-
-1. 통합 기능을 설치합니다(위 참고).
-2. **Set up**를 선택하면 브라우저에서 페이지가 열립니다. 접근을 승인한 뒤 OpenChamber로 돌아옵니다.
-
-화면이 없는 서버나 CI 환경에서는 브라우저 로그인 대신 `COMMAND_CODE_API_KEY` 환경 변수를 설정하세요.
-
## Cursor
Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다.
diff --git a/packages/docs/content/docs/ko/providers.mdx b/packages/docs/content/docs/ko/providers.mdx
index 11d9007f..98fb109d 100644
--- a/packages/docs/content/docs/ko/providers.mdx
+++ b/packages/docs/content/docs/ko/providers.mdx
@@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가
## 관련 항목
-- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용
+- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx
index aca4b269..7e3ddf2c 100644
--- a/packages/docs/content/docs/pl/integrations.mdx
+++ b/packages/docs/content/docs/pl/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Integracje
-description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy.
+description: Używaj subskrypcji Claude lub Cursor jako dostawcy.
---
# Integracje
Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**.
+> **Funkcja eksperymentalna.** Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko.
+
Dostępne integracje:
- **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API
-- **Command Code** — Twój plan Command Code
- **Cursor** — limity modeli z Twojego planu Cursor
## Instalacja integracji
@@ -29,19 +30,10 @@ Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez
1. Zainstaluj integrację (patrz wyżej).
2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie.
-Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Command Code i Cursor nie wymagają swoich CLI.
+Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI.
**Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania.
-## Command Code
-
-Command Code korzysta z Twojego planu Command Code.
-
-1. Zainstaluj integrację (patrz wyżej).
-2. Wybierz **Set up** — w przeglądarce otworzy się strona. Zatwierdź dostęp i wróć do OpenChamber.
-
-Na maszynie bez interfejsu graficznego lub w CI ustaw zmienną środowiskową `COMMAND_CODE_API_KEY` zamiast logowania w przeglądarce.
-
## Cursor
Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor.
diff --git a/packages/docs/content/docs/pl/providers.mdx b/packages/docs/content/docs/pl/providers.mdx
index 79d1ad41..4c777176 100644
--- a/packages/docs/content/docs/pl/providers.mdx
+++ b/packages/docs/content/docs/pl/providers.mdx
@@ -45,6 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc
## Powiązane
-- [Integracje](/pl/integrations/) — używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy
+- [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
- [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś
diff --git a/packages/docs/content/docs/providers.mdx b/packages/docs/content/docs/providers.mdx
index df7a7d1e..2505ced4 100644
--- a/packages/docs/content/docs/providers.mdx
+++ b/packages/docs/content/docs/providers.mdx
@@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit
## Related
-- [Integrations](/integrations/) — use a Claude, Command Code, or Cursor subscription as a provider
+- [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider
- [MCP Servers](/mcp/) — add extra tools for agents
- [Usage & Quotas](/usage/) — track how much you've used
diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx
index 1d66cbda..82ed2e68 100644
--- a/packages/docs/content/docs/pt-br/integrations.mdx
+++ b/packages/docs/content/docs/pt-br/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Integrações
-description: Use sua assinatura Claude, Command Code ou Cursor como provedor.
+description: Use sua assinatura Claude ou Cursor como provedor.
---
# Integrações
Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**.
+> **Recurso experimental.** Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco.
+
Integrações disponíveis:
- **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API
-- **Command Code** — seu plano Command Code
- **Cursor** — os limites de modelos do seu plano Cursor
## Instalar uma integração
@@ -29,19 +30,10 @@ O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app
1. Instale a integração (acima).
2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login.
-O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Command Code e Cursor não exigem suas CLIs.
+O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Cursor não exige sua CLI.
**Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados.
-## Command Code
-
-O Command Code usa seu plano Command Code.
-
-1. Instale a integração (acima).
-2. Escolha **Set up** — uma página abre no navegador. Autorize o acesso e volte ao OpenChamber.
-
-Em uma máquina sem interface gráfica ou em CI, defina a variável de ambiente `COMMAND_CODE_API_KEY` em vez de fazer login pelo navegador.
-
## Cursor
O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor.
diff --git a/packages/docs/content/docs/pt-br/providers.mdx b/packages/docs/content/docs/pt-br/providers.mdx
index b05dc222..70ece24f 100644
--- a/packages/docs/content/docs/pt-br/providers.mdx
+++ b/packages/docs/content/docs/pt-br/providers.mdx
@@ -45,6 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e
## Relacionado
-- [Integrações](/pt-br/integrations/) — use uma assinatura Claude, Command Code ou Cursor como provedor
+- [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor
- [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes
- [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou
diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx
index b68da46c..d6d075f4 100644
--- a/packages/docs/content/docs/uk/integrations.mdx
+++ b/packages/docs/content/docs/uk/integrations.mdx
@@ -1,16 +1,17 @@
---
title: Інтеграції
-description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів.
+description: Використовуйте підписки Claude або Cursor як провайдерів.
---
# Інтеграції
Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**.
+> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.
+
Доступні інтеграції:
- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів
-- **Command Code** — ваша підписка Command Code
- **Cursor** — ліміти моделей вашої підписки Cursor
## Встановлення інтеграції
@@ -29,19 +30,10 @@ Claude Code використовує вашу підписку Claude Pro або
1. Встановіть інтеграцію (вище).
2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід.
-Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Command Code і Cursor їхні CLI не потрібні.
+Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен.
**Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації.
-## Command Code
-
-Command Code використовує вашу підписку Command Code.
-
-1. Встановіть інтеграцію (вище).
-2. Натисніть **Set up** — відкриється сторінка в браузері. Підтвердьте доступ і поверніться до OpenChamber.
-
-На сервері без графічного інтерфейсу або в CI замість входу через браузер задайте змінну середовища `COMMAND_CODE_API_KEY`.
-
## Cursor
Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor.
diff --git a/packages/docs/content/docs/uk/providers.mdx b/packages/docs/content/docs/uk/providers.mdx
index 4fb61d65..9f2777e5 100644
--- a/packages/docs/content/docs/uk/providers.mdx
+++ b/packages/docs/content/docs/uk/providers.mdx
@@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте
## Пов'язане
-- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів
+- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx
index 94daf884..a5686e3c 100644
--- a/packages/docs/content/docs/zh-cn/integrations.mdx
+++ b/packages/docs/content/docs/zh-cn/integrations.mdx
@@ -1,16 +1,17 @@
---
title: 集成
-description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。
+description: 将你的 Claude 或 Cursor 订阅用作提供商。
---
# 集成
集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。
+> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。
+
可用的集成:
- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥
-- **Command Code** — 你的 Command Code 套餐
- **Cursor** — 你的 Cursor 套餐的模型额度
## 安装集成
@@ -29,19 +30,10 @@ Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也
1. 安装集成(见上文)。
2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。
-Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Command Code 和 Cursor 不需要它们的 CLI。
+Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要其 CLI。
**你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。
-## Command Code
-
-Command Code 使用你的 Command Code 套餐。
-
-1. 安装集成(见上文)。
-2. 选择 **Set up** — 浏览器中会打开一个页面。授权访问,然后返回 OpenChamber。
-
-在没有图形界面的服务器或 CI 环境中,请设置环境变量 `COMMAND_CODE_API_KEY` 来代替浏览器登录。
-
## Cursor
Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。
diff --git a/packages/docs/content/docs/zh-cn/providers.mdx b/packages/docs/content/docs/zh-cn/providers.mdx
index 62cf4019..5e81e7d3 100644
--- a/packages/docs/content/docs/zh-cn/providers.mdx
+++ b/packages/docs/content/docs/zh-cn/providers.mdx
@@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。
## 相关内容
-- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商
+- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
diff --git a/packages/electron/README.md b/packages/electron/README.md
index a7763d48..7a306894 100644
--- a/packages/electron/README.md
+++ b/packages/electron/README.md
@@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
## Native Features Owned Here
- Floating Mini Chat windows.
+- New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target.
- Multiple native windows.
- Native notifications.
- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried.
diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs
index c6fe8edb..9ecbead7 100644
--- a/packages/electron/main.mjs
+++ b/packages/electron/main.mjs
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import updaterPkg from 'electron-updater';
import { ElectronSshManager } from './ssh-manager.mjs';
+import { replaceFileWithRetry } from './windows-file-replace.mjs';
import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
@@ -560,10 +561,15 @@ const writeJsonFile = async (filePath, data) => {
// Atomic: write to a temp file then rename. Readers never see a partial
// JSON file that could parse-error and get coerced to {}.
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
- if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
- await fsp.rename(tmp, filePath);
- if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
+ try {
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
+ if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
+ await replaceFileWithRetry(tmp, filePath);
+ if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
+ } catch (error) {
+ await fsp.rm(tmp, { force: true }).catch(() => {});
+ throw error;
+ }
};
const readSettingsRoot = () => {
@@ -2465,6 +2471,9 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders };
browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders);
browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled;
+ browserWindow.on('app-command', (event, command) => {
+ if (command === 'browser-backward') event.preventDefault();
+ });
if (useSaved && saved.maximized) {
browserWindow.maximize();
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 8432487f..b4d03bec 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "@openchamber/electron",
- "version": "1.19.0",
+ "version": "1.20.0",
"private": true,
"description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber",
diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs
index 5c0b52ba..390c2a38 100644
--- a/packages/electron/ssh-manager.mjs
+++ b/packages/electron/ssh-manager.mjs
@@ -5,9 +5,30 @@ import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
+import { replaceFileWithRetry } from './windows-file-replace.mjs';
+
const LOCAL_HOST_ID = 'local';
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1';
+// Global npm prefixes are root-owned on most distributions, so `npm install -g`
+// fails with EACCES for a normal SSH user. Everything we install goes to a
+// prefix inside the user's home instead.
+const REMOTE_USER_PREFIX = '$HOME/.openchamber/npm-global';
+const REMOTE_BUN_CANDIDATE = '"${BUN_INSTALL:-$HOME/.bun}/bin/bun"';
+// The opencode CLI usually installs into the user's home, which an SSH login
+// shell does not have on PATH. The remote server only looks at OPENCODE_BINARY
+// and PATH, so resolve the CLI here and hand it over explicitly.
+const REMOTE_OPENCODE_CANDIDATES = [
+ '"$HOME/.opencode/bin/opencode"',
+ '"${BUN_INSTALL:-$HOME/.bun}/bin/opencode"',
+ '"$HOME/.local/bin/opencode"',
+ '"$HOME/.openchamber/npm-global/bin/opencode"',
+];
+const REMOTE_PATH_PREFIX = '$HOME/.opencode/bin:${BUN_INSTALL:-$HOME/.bun}/bin:$HOME/.local/bin:$HOME/.openchamber/npm-global/bin';
+const REMOTE_BIN_CANDIDATES = [
+ '"$HOME/.openchamber/npm-global/bin/openchamber"',
+ '"${BUN_INSTALL:-$HOME/.bun}/bin/openchamber"',
+];
const DEFAULT_CONTROL_PERSIST_SEC = 300;
const DEFAULT_READY_TIMEOUT_SEC = 30;
const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5;
@@ -77,10 +98,15 @@ const writeJsonRoot = async (settingsFilePath, root) => {
await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true });
// Atomic write: concurrent readers (main.mjs, web server) would otherwise
// see partial JSON and readJsonRoot()'s catch would silently coerce to {},
- // causing the next read-modify-write to wipe the entire settings file.
+ // causing the next read-modify-write wipe the entire settings file.
const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
- await fsp.rename(tmp, settingsFilePath);
+ try {
+ await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
+ await replaceFileWithRetry(tmp, settingsFilePath);
+ } catch (error) {
+ await fsp.rm(tmp, { force: true }).catch(() => {});
+ throw error;
+ }
};
const defaultTrue = () => true;
@@ -578,6 +604,7 @@ export class ElectronSshManager {
localUrl: null,
localPort: null,
remotePort: null,
+ remoteBinPath: null,
startedByUs: false,
retryAttempt: 0,
requiresUserAction: false,
@@ -804,9 +831,10 @@ export class ElectronSshManager {
mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed',
keepRunning: instance?.remoteOpenchamber?.keepRunning !== false,
...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}),
- installMethod: ['npm', 'bun', 'download_release', 'upload_bundle'].includes(instance?.remoteOpenchamber?.installMethod)
+ installMethod: ['auto', 'npm', 'bun'].includes(instance?.remoteOpenchamber?.installMethod)
? instance.remoteOpenchamber.installMethod
- : 'bun',
+ : 'auto',
+ bindHost: instance?.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1',
uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh),
},
localForward: {
@@ -981,38 +1009,77 @@ export class ElectronSshManager {
return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null;
}
- async remoteCommandExists(parsed, controlPath, commandName) {
- try {
- const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
- return output.trim() === 'yes';
- } catch {
- return false;
- }
- }
+ // A login shell over SSH does not source the user's interactive rc files, so
+ // tools installed into a home directory (bun above all) are missing from PATH
+ // even when they exist. Look at their known install locations too.
+ async resolveRemoteTool(parsed, controlPath, commandName, extraCandidates = []) {
+ const candidateList = [...extraCandidates, `"$(command -v ${commandName} 2>/dev/null)"`].join(' ');
+ const script = [
+ `for candidate in ${candidateList}; do`,
+ ' [ -n "$candidate" ] || continue;',
+ ' [ -x "$candidate" ] || continue;',
+ ` printf '%s' "$candidate";`,
+ ' exit 0;',
+ 'done',
+ ].join(' ');
- async currentRemoteOpenChamberVersion(parsed, controlPath) {
try {
- const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
- return parseVersionToken(output);
+ const output = await this.runRemoteCommand(parsed, controlPath, script);
+ return output.trim() || null;
} catch {
return null;
}
}
- async installOpenChamberManaged(parsed, controlPath, version, preferred) {
- const hasBun = await this.remoteCommandExists(parsed, controlPath, 'bun');
- const hasNpm = await this.remoteCommandExists(parsed, controlPath, 'npm');
- const commands = [];
+ // Every place OpenChamber may live on the remote host, with the version each
+ // one reports. Installs land in the user prefix while an older copy can still
+ // sit on PATH, so the caller picks by version instead of trusting PATH order.
+ async remoteOpenChamberCandidates(parsed, controlPath) {
+ const script = [
+ `for candidate in ${REMOTE_BIN_CANDIDATES.join(' ')} "$(command -v openchamber 2>/dev/null)"; do`,
+ ' [ -n "$candidate" ] || continue;',
+ ' [ -x "$candidate" ] || continue;',
+ ` printf '%s\t%s\n' "$candidate" "$("$candidate" --version 2>/dev/null | head -n 1)";`,
+ 'done',
+ ].join(' ');
- if (preferred === 'bun') {
- if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
- if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
- } else if (preferred === 'npm') {
- if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
- if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
+ let output = '';
+ try {
+ output = await this.runRemoteCommand(parsed, controlPath, script);
+ } catch {
+ return [];
+ }
+
+ const candidates = [];
+ const seen = new Set();
+ for (const line of output.split(/\r?\n/)) {
+ const [binPath, versionRaw] = line.split('\t');
+ const trimmed = (binPath || '').trim();
+ if (!trimmed || seen.has(trimmed)) continue;
+ seen.add(trimmed);
+ candidates.push({ binPath: trimmed, version: parseVersionToken(versionRaw || '') });
+ }
+ return candidates;
+ }
+
+ async installOpenChamberManaged(parsed, controlPath, version, preferred) {
+ const bunPath = await this.resolveRemoteTool(parsed, controlPath, 'bun', [REMOTE_BUN_CANDIDATE]);
+ const npmPath = await this.resolveRemoteTool(parsed, controlPath, 'npm');
+
+ // bun's global install already targets ~/.bun; npm is pinned to a prefix in
+ // the user's home so it never touches the root-owned global directory.
+ const bunCommand = bunPath ? `${shellQuote(bunPath)} add -g @openchamber/web@${version}` : null;
+ const npmCommand = npmPath
+ ? `mkdir -p "${REMOTE_USER_PREFIX}" && ${shellQuote(npmPath)} install -g --prefix "${REMOTE_USER_PREFIX}" @openchamber/web@${version}`
+ : null;
+
+ const commands = [];
+ if (preferred === 'npm') {
+ if (npmCommand) commands.push(npmCommand);
+ if (bunCommand) commands.push(bunCommand);
} else {
- if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
- if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
+ if (bunCommand) commands.push(bunCommand);
+ if (npmCommand) commands.push(npmCommand);
}
if (commands.length === 0) {
@@ -1072,24 +1139,35 @@ export class ElectronSshManager {
}
}
- async startRemoteServerManaged(parsed, controlPath, instance, desiredPort) {
- let envPrefix = 'OPENCHAMBER_RUNTIME=ssh-remote';
+ async startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binPath) {
+ const opencodePath = await this.resolveRemoteTool(parsed, controlPath, 'opencode', REMOTE_OPENCODE_CANDIDATES);
+ if (!opencodePath) {
+ throw new Error('The opencode CLI is not installed on the remote machine. Install it there, then connect again');
+ }
+
const secret = this.configuredOpenChamberPassword(instance);
+ const remoteBindHost = instance.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1';
+ // Binding the remote server to every interface publishes its UI to the
+ // remote machine's whole network, so it may not run without a password.
+ if (remoteBindHost === '0.0.0.0' && !secret) {
+ throw new Error('Exposing the remote server to its network requires a UI password');
+ }
+
+ let envPrefix = `PATH="${REMOTE_PATH_PREFIX}:$PATH" OPENCODE_BINARY=${shellQuote(opencodePath)} OPENCHAMBER_RUNTIME=ssh-remote`;
if (secret) {
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
}
- const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
+ const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} ${shellQuote(binPath)} serve --hostname ${remoteBindHost} --port ${desiredPort}`);
const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value));
return port || desiredPort;
}
- async stopRemoteServerBestEffort(parsed, controlPath, remotePort) {
+ // `openchamber stop` owns the daemon lifecycle. The HTTP shutdown route sits
+ // behind UI authentication, so it cannot stop a password-protected server.
+ async stopRemoteServerBestEffort(parsed, controlPath, remotePort, remoteBinPath) {
+ if (!remoteBinPath) return;
try {
- await this.runRemoteCommand(
- parsed,
- controlPath,
- `if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`,
- );
+ await this.runRemoteCommand(parsed, controlPath, `${shellQuote(remoteBinPath)} stop --port ${remotePort}`);
} catch {
}
}
@@ -1143,17 +1221,27 @@ export class ElectronSshManager {
const port = instance.remoteOpenchamber.preferredPort;
this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false);
await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance));
- return { remotePort: port, startedByUs: false };
+ return { remotePort: port, startedByUs: false, remoteBinPath: null };
}
this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation');
- const installedVersion = await this.currentRemoteOpenChamberVersion(parsed, controlPath);
- if (!installedVersion) {
- this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
- await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
- } else if (installedVersion !== this.appVersion) {
- this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${installedVersion} to ${this.appVersion}`);
+ const installed = await this.remoteOpenChamberCandidates(parsed, controlPath);
+ let binary = installed.find((candidate) => candidate.version === this.appVersion) || null;
+
+ if (!binary) {
+ const existing = installed[0] || null;
+ if (existing) {
+ this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${existing.version || 'unknown'} to ${this.appVersion}`);
+ } else {
+ this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
+ }
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
+
+ const afterInstall = await this.remoteOpenChamberCandidates(parsed, controlPath);
+ binary = afterInstall.find((candidate) => candidate.version === this.appVersion) || afterInstall[0] || existing;
+ if (!binary) {
+ throw new Error('OpenChamber was installed on the remote host but no openchamber binary could be found');
+ }
}
this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server');
@@ -1165,13 +1253,13 @@ export class ElectronSshManager {
if (!remotePort) {
this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server');
const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id);
- remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort);
+ remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binary.binPath);
startedByUs = true;
}
if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) {
throw new Error('Managed OpenChamber server failed to become reachable');
}
- return { remotePort, startedByUs };
+ return { remotePort, startedByUs, remoteBinPath: binary.binPath };
}
async disconnectInternal(id, reportIdle) {
@@ -1186,7 +1274,7 @@ export class ElectronSshManager {
if (session) {
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
- await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
+ await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort, session.remoteBinPath);
}
await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
const auth = this.sshAuth.get(session.parsed);
@@ -1262,9 +1350,10 @@ export class ElectronSshManager {
throw new Error(`Unsupported remote OS: ${remoteOs}`);
}
- const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath);
+ const { remotePort, startedByUs, remoteBinPath } = await this.ensureRemoteServer(instance, parsed, controlPath);
session.remotePort = remotePort;
session.startedByUs = startedByUs;
+ session.remoteBinPath = remoteBinPath;
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs
index eb5d1af7..fdbf84ea 100644
--- a/packages/electron/ssh-manager.test.mjs
+++ b/packages/electron/ssh-manager.test.mjs
@@ -289,4 +289,161 @@ describe('ElectronSshManager', () => {
});
expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]);
});
+ test('installs OpenChamber into a home-owned npm prefix instead of the root-owned global one', async () => {
+ const commands = [];
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ manager.resolveRemoteTool = async (_parsed, _controlPath, name) => (name === 'npm' ? '/usr/bin/npm' : null);
+ manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
+ commands.push(script);
+ return '';
+ };
+
+ await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
+
+ expect(commands).toHaveLength(1);
+ expect(commands[0]).toContain('--prefix "$HOME/.openchamber/npm-global"');
+ expect(commands[0]).not.toMatch(/npm install -g @openchamber/);
+ });
+
+ test('lists every remote OpenChamber binary with its reported version', async () => {
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ manager.runRemoteCommand = async () => [
+ '/home/pi/.openchamber/npm-global/bin/openchamber\t1.2.3',
+ '/usr/bin/openchamber\t0.9.0',
+ '',
+ ].join('\n');
+
+ const candidates = await manager.remoteOpenChamberCandidates({ destination: 'user@example.test', args: [] }, '/tmp/control.sock');
+
+ expect(candidates).toEqual([
+ { binPath: '/home/pi/.openchamber/npm-global/bin/openchamber', version: '1.2.3' },
+ { binPath: '/usr/bin/openchamber', version: '0.9.0' },
+ ]);
+ });
+
+ test('starts the resolved OpenChamber binary rather than whatever PATH exposes', async () => {
+ let started = '';
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
+ manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
+ started = script;
+ return '4321\n';
+ };
+
+ const instance = { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } };
+ const port = await manager.startRemoteServerManaged(
+ { destination: 'user@example.test', args: [] },
+ '/tmp/control.sock',
+ instance,
+ 4321,
+ '/home/pi/.openchamber/npm-global/bin/openchamber',
+ );
+
+ expect(port).toBe(4321);
+ expect(started).toContain("'/home/pi/.openchamber/npm-global/bin/openchamber' serve");
+ expect(started).toContain("OPENCODE_BINARY='/home/pi/.opencode/bin/opencode'");
+ expect(started).toContain('$HOME/.opencode/bin:');
+ });
+
+ test('refuses to start when the remote machine has no opencode CLI', async () => {
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ manager.resolveRemoteTool = async () => null;
+ manager.runRemoteCommand = async () => {
+ throw new Error('should not start the server without a CLI');
+ };
+
+ await expect(manager.startRemoteServerManaged(
+ { destination: 'user@example.test', args: [] },
+ '/tmp/control.sock',
+ { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } },
+ 4321,
+ '/home/pi/.bun/bin/openchamber',
+ )).rejects.toThrow(/opencode CLI is not installed/);
+ });
+ test('prefers a bun that only exists in the home directory over npm', async () => {
+ const commands = [];
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ // A login shell over SSH does not put ~/.bun/bin on PATH.
+ manager.resolveRemoteTool = async (_parsed, _controlPath, name) =>
+ (name === 'bun' ? '/home/pi/.bun/bin/bun' : '/usr/bin/npm');
+ manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
+ commands.push(script);
+ return '';
+ };
+
+ await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
+
+ expect(commands).toEqual(["'/home/pi/.bun/bin/bun' add -g @openchamber/web@1.2.3"]);
+ });
+ test('stops a remote server it started through the CLI, not the authenticated HTTP route', async () => {
+ const scripts = [];
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
+ scripts.push(script);
+ return '';
+ };
+
+ await manager.stopRemoteServerBestEffort(
+ { destination: 'user@example.test', args: [] },
+ '/tmp/control.sock',
+ 41777,
+ '/home/pi/.bun/bin/openchamber',
+ );
+
+ expect(scripts).toEqual(["'/home/pi/.bun/bin/openchamber' stop --port 41777"]);
+ });
+ test('publishes the remote server to its network only with a UI password', async () => {
+ const manager = new ElectronSshManager({
+ settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
+ appVersion: '1.2.3',
+ emit: () => undefined,
+ });
+ manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
+ let started = '';
+ manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
+ started = script;
+ return '4321\n';
+ };
+
+ const parsed = { destination: 'user@example.test', args: [] };
+ const exposed = {
+ id: 'ssh-1',
+ auth: {},
+ remoteOpenchamber: { mode: 'managed', bindHost: '0.0.0.0' },
+ };
+
+ await expect(manager.startRemoteServerManaged(parsed, '/tmp/control.sock', exposed, 4321, '/bin/openchamber'))
+ .rejects.toThrow(/requires a UI password/);
+
+ const secured = {
+ ...exposed,
+ auth: { openchamberPassword: { enabled: true, value: 'remote-secret', store: 'settings' } },
+ };
+ await manager.startRemoteServerManaged(parsed, '/tmp/control.sock', secured, 4321, '/bin/openchamber');
+ expect(started).toContain('--hostname 0.0.0.0');
+ });
});
diff --git a/packages/electron/windows-file-replace.mjs b/packages/electron/windows-file-replace.mjs
new file mode 100644
index 00000000..b43ad904
--- /dev/null
+++ b/packages/electron/windows-file-replace.mjs
@@ -0,0 +1,28 @@
+import fsp from 'node:fs/promises';
+
+const WINDOWS_RETRY_DELAYS_MS = [50, 100, 200, 400, 800, 1_000, 1_000];
+
+const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+const isTransientWindowsFileError = (error, platform) => {
+ if (platform !== 'win32') return false;
+ const code = error?.code;
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
+};
+
+export const replaceFileWithRetry = async (source, target, options = {}) => {
+ const platform = options.platform ?? process.platform;
+ const rename = options.rename ?? fsp.rename;
+ const wait = options.wait ?? sleep;
+
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ await rename(source, target);
+ return;
+ } catch (error) {
+ const delay = WINDOWS_RETRY_DELAYS_MS[attempt];
+ if (delay === undefined || !isTransientWindowsFileError(error, platform)) throw error;
+ await wait(delay);
+ }
+ }
+};
diff --git a/packages/electron/windows-file-replace.test.mjs b/packages/electron/windows-file-replace.test.mjs
new file mode 100644
index 00000000..a860037d
--- /dev/null
+++ b/packages/electron/windows-file-replace.test.mjs
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { replaceFileWithRetry } from './windows-file-replace.mjs';
+
+const fileError = (code = 'EPERM') => Object.assign(new Error(code), { code });
+
+test('retries transient Windows rename failures until replacement succeeds', async () => {
+ const delays = [];
+ let attempts = 0;
+
+ await replaceFileWithRetry('settings.tmp', 'settings.json', {
+ platform: 'win32',
+ rename: async () => {
+ attempts += 1;
+ if (attempts < 4) throw fileError();
+ },
+ wait: async (delay) => delays.push(delay),
+ });
+
+ assert.equal(attempts, 4);
+ assert.deepEqual(delays, [50, 100, 200]);
+});
+
+test('does not retry rename errors that are not transient Windows locks', async () => {
+ let attempts = 0;
+ const error = fileError('ENOENT');
+
+ await assert.rejects(
+ replaceFileWithRetry('settings.tmp', 'settings.json', {
+ platform: 'win32',
+ rename: async () => {
+ attempts += 1;
+ throw error;
+ },
+ wait: async () => assert.fail('unexpected wait'),
+ }),
+ error,
+ );
+
+ assert.equal(attempts, 1);
+});
+
+test('does not retry transient error codes outside Windows', async () => {
+ let attempts = 0;
+ const error = fileError();
+
+ await assert.rejects(
+ replaceFileWithRetry('settings.tmp', 'settings.json', {
+ platform: 'linux',
+ rename: async () => {
+ attempts += 1;
+ throw error;
+ },
+ wait: async () => assert.fail('unexpected wait'),
+ }),
+ error,
+ );
+
+ assert.equal(attempts, 1);
+});
+
+test('returns the final Windows lock error after the retry window', async () => {
+ const delays = [];
+ let attempts = 0;
+
+ await assert.rejects(
+ replaceFileWithRetry('settings.tmp', 'settings.json', {
+ platform: 'win32',
+ rename: async () => {
+ attempts += 1;
+ throw fileError();
+ },
+ wait: async (delay) => delays.push(delay),
+ }),
+ { code: 'EPERM' },
+ );
+
+ assert.equal(attempts, 8);
+ assert.deepEqual(delays, [50, 100, 200, 400, 800, 1_000, 1_000]);
+});
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 2b224dcb..14eaf104 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@openchamber/ui",
- "version": "1.19.0",
+ "version": "1.20.0",
"private": true,
"type": "module",
"main": "src/main.tsx",
@@ -44,7 +44,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
- "@opencode-ai/sdk": "1.18.18",
+ "@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0",
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index ff0ffe7c..5c9e8806 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
+import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
@@ -11,6 +12,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useTraySync } from '@/hooks/useTraySync';
+import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
@@ -33,7 +35,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR
import { useSessionUIStore } from '@/sync/session-ui-store';
import { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
-import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
@@ -639,12 +640,9 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
if (typeof window === 'undefined') return;
const onOpenMiniChat = () => {
- const currentDir = useDirectoryStore.getState().currentDirectory;
- const { activeProjectId, projects } = useProjectsStore.getState();
- const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
- directory: currentDir || activeProject?.path || '',
- projectId: activeProject?.id ?? null,
+ directory: '',
+ projectId: null,
});
};
window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
@@ -676,11 +674,13 @@ function App({ apis }: AppProps) {
const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0
? detail.projectId.trim()
: null;
+ const hasProjectTarget = Boolean(directory || projectId);
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({
- selectedProjectId: projectId,
- directoryOverride: directory,
+ target: hasProjectTarget ? 'project' : 'chat',
+ selectedProjectId: hasProjectTarget ? projectId : null,
+ directoryOverride: hasProjectTarget ? directory : null,
preserveDirectoryOverride: Boolean(directory),
});
};
@@ -721,6 +721,7 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
useTraySync();
+ useGlobalSessionsPolling(!embeddedSessionChat);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
@@ -908,6 +909,7 @@ function App({ apis }: AppProps) {
isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
+
@@ -951,6 +953,7 @@ function App({ apis }: AppProps) {
+
{!isBootShell && (
<>
diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx
index 10a993d8..7aed5ba0 100644
--- a/packages/ui/src/apps/ElectronMiniChatApp.tsx
+++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx
@@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
+import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
@@ -25,6 +26,7 @@ import {
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
+import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
@@ -153,9 +155,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return;
if (useSessionUIStore.getState().currentSessionId === sessionId) return;
- const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
- ? detail.directory.trim()
- : (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null;
+ const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim();
+ const directory = sessionDirectory
+ || (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null);
void sync.ensureSessionRenderable(sessionId);
setCurrentSession(sessionId, directory);
sessionBootstrappedRef.current = true;
@@ -166,9 +168,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
+ const hasProjectTarget = Boolean(config.projectId || config.directory);
openNewSessionDraft({
- selectedProjectId: config.projectId,
- directoryOverride: config.directory,
+ target: hasProjectTarget ? 'project' : 'chat',
+ selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID,
+ directoryOverride: hasProjectTarget ? config.directory : null,
preserveDirectoryOverride: Boolean(config.directory),
});
}, [config, currentSessionId, draftOpen, openNewSessionDraft]);
@@ -278,10 +282,11 @@ const MiniChatPresencePublisher: React.FC = () => {
const useSessionUnavailable = (config: MiniChatConfig): boolean => {
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
+ const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
const [timedOut, setTimedOut] = React.useState(false);
React.useEffect(() => {
- if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) {
+ if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) {
setTimedOut(false);
return;
}
@@ -291,7 +296,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => {
}
const timeout = window.setTimeout(() => setTimedOut(true), 5000);
return () => window.clearTimeout(timeout);
- }, [config.mode, config.sessionId, currentSessionId, sessions]);
+ }, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]);
return timedOut;
};
@@ -321,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
+
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx
index b790270c..4d30dc00 100644
--- a/packages/ui/src/apps/MobileApp.tsx
+++ b/packages/ui/src/apps/MobileApp.tsx
@@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView';
+import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
}} />
+
{isInitialized ? : null}
diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx
index 47b8086c..737a0239 100644
--- a/packages/ui/src/apps/VSCodeApp.tsx
+++ b/packages/ui/src/apps/VSCodeApp.tsx
@@ -8,8 +8,10 @@ import { Toaster } from '@/components/ui/sonner';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
+import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
+import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
@@ -56,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
useRouter();
+ useGlobalSessionsPolling(panelType !== 'agentManager');
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
@@ -108,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
- );
- }
-
- if (sessionMessages.length === 0 && !sessionIsWorking) {
- return (
- // No transform here either — same fixed-positioning constraint as the
- // draft branch above.
-
is running…" row sits flush against
- // the message above.
- className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
+ // This row must land exactly where the assistant turn footer (mt-2
+ // inside the message) appears when the turn completes. Measured against
+ // the live DOM: the gap ABOVE already matches (message pb-2 = footer
+ // mt-2 = 8px), but the chat is bottom-anchored and the finished message
+ // carries ~12px more structure BELOW its footer than this row has — so
+ // the swap used to lift the line up. mb-6 (24px) reserves that space
+ // under this row instead (verified: row top 636 == footer top 636).
+ className={cn("mb-6", !hasLeftAccessory && "chat-column")}
style={STATUS_ROW_CONTAINER_STYLE}
>
-
+ {/* h-8 matches the turn footer's real row height: its h-8 action
+ buttons define the footer line, with the meta text centered in it. */}
+
{/* Left: Abort status | Working placeholder | leftAccessory */}
{showAssistantStatus && showAbortStatus ? (
diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
index cd72ed46..54ed84d2 100644
--- a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
+++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
@@ -226,20 +226,23 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
});
- test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
+ test('the empty and idle branch leaves the status row to the busy path', () => {
+ // A busy session with no messages yet must fall through to the viewport so
+ // StatusRowContainer is the only thing on screen. The idle branch returns
+ // before it and must not render one of its own. The empty state itself no
+ // longer lives here: the draft surface owns it since the draft transition
+ // animation landed.
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
- expect(chatContainerSource).toContain('');
- const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
- const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard);
- expect(emptyStateReturn).toBeGreaterThan(-1);
- const emptyStateBlock = chatContainerSource.slice(
- emptyStateReturn,
- emptyStateReturn + 1600,
+ const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
+ const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
+ expect(emptyIdleReturn).toBeGreaterThan(-1);
+ const emptyIdleBlock = chatContainerSource.slice(
+ emptyIdleReturn,
+ emptyIdleReturn + 1600,
);
- expect(emptyStateBlock).toContain('');
+ expect(emptyIdleBlock).not.toContain('');
});
test('visibility handshake remains as defense-in-depth for background work', () => {
diff --git a/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts b/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts
new file mode 100644
index 00000000..ff341f3f
--- /dev/null
+++ b/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts
@@ -0,0 +1,33 @@
+/**
+ * Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
+ *
+ * Restoring persisted agent/model pairs used to switch agents before checking
+ * whether each model still existed. Several stale pairs could therefore keep
+ * changing the active agent on every effect pass until React hit its nested
+ * update limit. The API error belongs in the assistant message; an invalid
+ * persisted pair must not mutate the current selection while it is rendered.
+ */
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
+
+describe('issue #3036 stale persisted models', () => {
+ test('changes the agent only after its persisted model is accepted', () => {
+ const candidateLoop = modelControlsSource.slice(
+ modelControlsSource.indexOf('for (const agent of agents)'),
+ modelControlsSource.indexOf("return 'continue';"),
+ );
+
+ const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
+ const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
+ const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
+
+ expect(applyIndex).toBeGreaterThanOrEqual(0);
+ expect(acceptedIndex).toBeGreaterThan(applyIndex);
+ expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
+ });
+});
diff --git a/packages/ui/src/components/chat/appLinkConfirmation.test.ts b/packages/ui/src/components/chat/appLinkConfirmation.test.ts
new file mode 100644
index 00000000..1fb41557
--- /dev/null
+++ b/packages/ui/src/components/chat/appLinkConfirmation.test.ts
@@ -0,0 +1,67 @@
+import { beforeEach, describe, expect, test } from 'bun:test';
+
+import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
+
+import {
+ getAppLinkConfirmationSnapshot,
+ openAppLinkWithConfirmation,
+ settleAppLinkConfirmation,
+} from './appLinkConfirmation';
+
+describe('app link confirmation', () => {
+ beforeEach(() => {
+ useAppLinkTrustStore.setState({ trustedSchemes: [] });
+ const pending = getAppLinkConfirmationSnapshot();
+ if (pending) {
+ settleAppLinkConfirmation('cancel');
+ }
+ });
+
+ test('opens trusted schemes without asking', async () => {
+ useAppLinkTrustStore.getState().trustScheme('obsidian');
+
+ await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
+
+ expect(getAppLinkConfirmationSnapshot()).toBeNull();
+ expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
+ });
+
+ test('asks once and trusts the scheme when the user chooses trust', async () => {
+ const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
+
+ expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
+
+ settleAppLinkConfirmation('trust');
+ await pending;
+
+ expect(getAppLinkConfirmationSnapshot()).toBeNull();
+ expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
+ });
+
+ test('cancel opens nothing and keeps the scheme untrusted', async () => {
+ const pending = openAppLinkWithConfirmation('notion://note/xyz');
+
+ settleAppLinkConfirmation('cancel');
+ await pending;
+
+ expect(getAppLinkConfirmationSnapshot()).toBeNull();
+ expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
+ });
+
+ test('a newer request cancels the pending one', async () => {
+ const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
+ const firstChoice = first.then(
+ () => 'settled',
+ () => 'settled',
+ );
+ const second = openAppLinkWithConfirmation('linear://open/1');
+
+ expect(await firstChoice).toBe('settled');
+ expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
+
+ settleAppLinkConfirmation('open');
+ await second;
+
+ expect(getAppLinkConfirmationSnapshot()).toBeNull();
+ });
+});
diff --git a/packages/ui/src/components/chat/appLinkConfirmation.ts b/packages/ui/src/components/chat/appLinkConfirmation.ts
new file mode 100644
index 00000000..d9eea31e
--- /dev/null
+++ b/packages/ui/src/components/chat/appLinkConfirmation.ts
@@ -0,0 +1,71 @@
+import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
+import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
+
+export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
+
+type PendingAppLinkRequest = {
+ url: string;
+ resolve: (choice: AppLinkConfirmationChoice) => void;
+};
+
+let pendingRequest: PendingAppLinkRequest | null = null;
+const listeners = new Set<() => void>();
+
+const emitChange = (): void => {
+ for (const listener of listeners) {
+ listener();
+ }
+};
+
+const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
+
+const subscribe = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+/**
+ * Ask the user (via the app-level confirmation dialog) whether an application
+ * deep link may be opened. Resolves immediately when the scheme was trusted
+ * earlier. Only one request is active at a time; a new request cancels the
+ * pending one.
+ */
+export const openAppLinkWithConfirmation = (url: string): Promise => {
+ const scheme = getUrlScheme(url);
+ if (!scheme) {
+ return Promise.resolve();
+ }
+
+ const trustStore = useAppLinkTrustStore.getState();
+ if (trustStore.isSchemeTrusted(scheme)) {
+ return openConfirmedAppLinkUrl(url).then(() => undefined);
+ }
+
+ if (pendingRequest) {
+ pendingRequest.resolve('cancel');
+ }
+
+ return new Promise((resolve) => {
+ pendingRequest = { url, resolve };
+ emitChange();
+ }).then((choice) => {
+ if (choice === 'trust') {
+ useAppLinkTrustStore.getState().trustScheme(scheme);
+ }
+ if (choice === 'open' || choice === 'trust') {
+ return openConfirmedAppLinkUrl(url).then(() => undefined);
+ }
+ });
+};
+
+export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
+ const request = pendingRequest;
+ pendingRequest = null;
+ emitChange();
+ request?.resolve(choice);
+};
+
+export const subscribeAppLinkConfirmation = subscribe;
+export const getAppLinkConfirmationSnapshot = getSnapshot;
diff --git a/packages/ui/src/components/chat/appLinkInteractions.test.ts b/packages/ui/src/components/chat/appLinkInteractions.test.ts
new file mode 100644
index 00000000..e32acbd9
--- /dev/null
+++ b/packages/ui/src/components/chat/appLinkInteractions.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test } from 'bun:test';
+
+import { attachAppLinkInteractions } from './appLinkInteractions';
+
+const TestElement = class Element {};
+const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
+Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
+
+class TestAnchor extends HTMLAnchorElement {
+ constructor(private readonly rawHref: string) {
+ super();
+ }
+
+ getAttribute(name: string): string | null {
+ return name === 'href' ? this.rawHref : null;
+ }
+
+ closest(): TestAnchor {
+ return this;
+ }
+}
+
+class TestContainer {
+ listeners = new Map();
+
+ addEventListener(name: string, listener: (event: MouseEvent) => void): void {
+ // SAFETY: dispatch constructs every mouse field read by the production listener.
+ this.listeners.set(name, (event) => listener(event as MouseEvent));
+ }
+
+ removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
+ void listener;
+ this.listeners.delete(name);
+ }
+
+ dispatch(name: string, href: string, init: Partial = {}): Event {
+ const event = new Event(name, { cancelable: true });
+ Object.defineProperties(event, {
+ target: { value: new TestAnchor(href) },
+ button: { value: init.button ?? 0 },
+ metaKey: { value: init.metaKey ?? false },
+ ctrlKey: { value: init.ctrlKey ?? false },
+ altKey: { value: init.altKey ?? false },
+ shiftKey: { value: init.shiftKey ?? false },
+ });
+ this.listeners.get(name)?.(event);
+ return event;
+ }
+}
+
+const setup = (allowExternalHttp = true) => {
+ const container = new TestContainer();
+ const appLinks: string[] = [];
+ const httpLinks: string[] = [];
+ const cleanup = attachAppLinkInteractions(container, {
+ allowExternalHttp,
+ openAppLink: (url) => appLinks.push(url),
+ openExternalHttp: (url) => httpLinks.push(url),
+ });
+ return { container, appLinks, httpLinks, cleanup };
+};
+
+describe('app link interactions', () => {
+ test('confirms plain, modifier, and middle-click activations', () => {
+ const { container, appLinks } = setup();
+ const href = 'obsidian://open?vault=Notes';
+
+ expect(container.dispatch('click', href).defaultPrevented).toBe(true);
+ expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
+ expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
+ expect(appLinks).toEqual([href, href, href]);
+ });
+
+ test('blocks drag activation without opening immediately', () => {
+ const { container, appLinks } = setup();
+ const href = 'obsidian://open?vault=Notes';
+
+ expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
+ expect(appLinks).toEqual([]);
+ });
+
+ test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
+ const enabled = setup();
+ const disabled = setup(false);
+ const href = 'https://example.com';
+
+ expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
+ expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
+ expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
+ expect(enabled.httpLinks).toEqual([href]);
+ expect(disabled.httpLinks).toEqual([]);
+ });
+});
diff --git a/packages/ui/src/components/chat/appLinkInteractions.ts b/packages/ui/src/components/chat/appLinkInteractions.ts
new file mode 100644
index 00000000..595e671a
--- /dev/null
+++ b/packages/ui/src/components/chat/appLinkInteractions.ts
@@ -0,0 +1,75 @@
+import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
+
+type AppLinkInteractionOptions = {
+ allowExternalHttp: boolean;
+ openAppLink: (url: string) => void;
+ openExternalHttp: (url: string) => void;
+};
+
+type LinkInteractionContainer = {
+ addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
+ removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
+};
+
+const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
+ const target = event.target;
+ if (!(target instanceof Element)) return null;
+ const anchor = target.closest('a[href]');
+ if (!(anchor instanceof HTMLAnchorElement)) return null;
+ if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
+ return anchor;
+};
+
+const interceptAppLink = (
+ event: MouseEvent | DragEvent,
+ openAppLink?: (url: string) => void,
+): boolean => {
+ if (event.defaultPrevented) return false;
+ const anchor = findLink(event);
+ const href = anchor?.getAttribute('href') ?? '';
+ if (!isAppLinkUrl(href)) return false;
+
+ event.preventDefault();
+ event.stopPropagation();
+ openAppLink?.(href);
+ return true;
+};
+
+const isPlainPrimaryClick = (event: MouseEvent): boolean => (
+ event.button === 0
+ && !event.metaKey
+ && !event.ctrlKey
+ && !event.altKey
+ && !event.shiftKey
+);
+
+export const attachAppLinkInteractions = (
+ container: LinkInteractionContainer,
+ options: AppLinkInteractionOptions,
+): (() => void) => {
+ const handleClick = (event: MouseEvent) => {
+ if (interceptAppLink(event, options.openAppLink)) return;
+ if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
+
+ const href = findLink(event)?.getAttribute('href') ?? '';
+ if (!isExternalHttpUrl(href)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ options.openExternalHttp(href);
+ };
+ const handleAuxClick = (event: MouseEvent) => {
+ if (event.button === 1) interceptAppLink(event, options.openAppLink);
+ };
+ const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
+ interceptAppLink(event);
+ };
+
+ container.addEventListener('click', handleClick);
+ container.addEventListener('auxclick', handleAuxClick);
+ container.addEventListener('dragstart', blockAlternateAppLinkActivation);
+ return () => {
+ container.removeEventListener('click', handleClick);
+ container.removeEventListener('auxclick', handleAuxClick);
+ container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
+ };
+};
diff --git a/packages/ui/src/components/chat/btw/BtwPanel.tsx b/packages/ui/src/components/chat/btw/BtwPanel.tsx
new file mode 100644
index 00000000..ecae7ba3
--- /dev/null
+++ b/packages/ui/src/components/chat/btw/BtwPanel.tsx
@@ -0,0 +1,481 @@
+import React from 'react';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import { useI18n } from '@/lib/i18n';
+import { cn } from '@/lib/utils';
+import { toast } from '@/components/ui';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/icon/Icon';
+import { useBtwStore } from '@/stores/useBtwStore';
+import { useSync } from '@/sync/use-sync';
+import {
+ useSessionMessageRecords,
+ useSessionRenderable,
+ useSessionStatus,
+ useScopedBlockingPermissions,
+ useScopedBlockingQuestions,
+} from '@/sync/sync-context';
+import { useStreamingStore } from '@/sync/streaming';
+import { ScrollShadow } from '@/components/ui/ScrollShadow';
+import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
+import type { BtwPanelState } from './useBtwPanelState';
+import { ChatSurfaceProvider } from '../ChatSurfaceContext';
+import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
+import ChatMessage from '../ChatMessage';
+import { PermissionCard } from '../PermissionCard';
+import { QuestionCard } from '../QuestionCard';
+
+const IDLE_SESSION_STATUS = { type: 'idle' as const };
+
+/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
+const NOOP_CONTENT_CHANGE = (): void => {};
+
+/**
+ * The `/btw` peek panel.
+ *
+ * Rendered from inside the composer form, so the sheet docks exactly above
+ * the main composer (`absolute bottom-full` on the composer column) on both
+ * desktop and mobile — the main composer IS the btw input, so nothing may
+ * cover it. Identity is derived from the parent session's metadata (see
+ * `useBtwPanelState`), so the panel belongs to one parent session only.
+ *
+ * Three exits: collapse (panel minimizes to the composer chip, the composer
+ * returns to the main session), promote (the fork becomes a normal session
+ * and the app navigates to it), destroy (the fork is deleted; the main
+ * conversation is never touched).
+ */
+export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
+ parentSessionId,
+ panel,
+}) => {
+ const { t } = useI18n();
+
+ if (panel.btwSessionId && panel.btwDirectory) {
+ return (
+
+ );
+ }
+
+ if (panel.creating) {
+ return (
+
+
+
+ {t('chat.btw.loading')}
+
+
+ );
+ }
+
+ return null;
+};
+
+const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
+ const { t } = useI18n();
+ return React.useCallback(() => {
+ if (!sessionRef) return;
+ void destroyBtwSession(sessionRef).then((ok) => {
+ if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
+ });
+ }, [sessionRef, t]);
+};
+
+type BtwSessionData = {
+ messageRecords: Array<{ info: Message; parts: Part[] }>;
+ sessionIsWorking: boolean;
+ streamingMessageId: string | null;
+ activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
+ sessionPermissions: ReturnType;
+ sessionQuestions: ReturnType;
+ isEmpty: boolean;
+};
+
+/**
+ * Live session data for the fork, all keyed by the fork's own ids. Only the
+ * fork's tail (messages after the inherited-history boundary) is shown.
+ */
+const useBtwSessionData = (
+ sessionId: string,
+ directory: string,
+ boundaryMessageID: string | null,
+): BtwSessionData => {
+ const sync = useSync();
+ const renderable = useSessionRenderable(sessionId, directory);
+ React.useEffect(() => {
+ if (!renderable) {
+ void sync.ensureSessionRenderable(sessionId, false, directory);
+ }
+ }, [directory, renderable, sessionId, sync]);
+
+ const messageRecords = useSessionMessageRecords(sessionId, directory);
+ const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
+ const streamingMessageId = useStreamingStore(
+ React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
+ );
+ const activeStreamingPhase = useStreamingStore(
+ React.useCallback(
+ (s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
+ [streamingMessageId],
+ ),
+ );
+ const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
+ const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
+
+ const tailRecords = React.useMemo(
+ () => filterBtwTailMessages(messageRecords, boundaryMessageID),
+ [boundaryMessageID, messageRecords],
+ );
+
+ const sessionIsWorking = React.useMemo(() => {
+ if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
+ return false;
+ }
+ const statusType = status.type ?? 'idle';
+ if (statusType === 'busy' || statusType === 'retry') {
+ return true;
+ }
+ // SAFETY: reads only the optional `time.completed` field, which the
+ // SDK Message union does not expose uniformly; a missing value means
+ // the assistant turn has not completed.
+ const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
+ return Boolean(
+ lastMessage
+ && lastMessage.role === 'assistant'
+ && typeof lastMessage.time?.completed !== 'number',
+ );
+ }, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
+
+ return {
+ messageRecords: tailRecords,
+ sessionIsWorking,
+ streamingMessageId,
+ activeStreamingPhase,
+ sessionPermissions,
+ sessionQuestions,
+ isEmpty: tailRecords.length === 0,
+ };
+};
+
+/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
+const useEscapeToCollapse = (onCollapse: () => void): void => {
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return;
+ // SAFETY: keydown targets are DOM elements (or null on window).
+ const target = event.target as HTMLElement | null;
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
+ return;
+ }
+ onCollapse();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onCollapse]);
+};
+
+/**
+ * Stick-to-bottom auto-scroll. Streaming grows content inside one message
+ * without changing the record count, so following the tail needs a
+ * ResizeObserver on the content wrapper — data-driven effects alone would
+ * stop following mid-stream.
+ */
+const useAutoScroll = (
+ bodyRef: React.RefObject,
+ contentRef: React.RefObject,
+ contentReady: boolean,
+): ((event: React.UIEvent) => void) => {
+ const stickToBottomRef = React.useRef(true);
+ // `contentReady` is a dependency because the refs are only attached once
+ // the empty state gives way to the message list; an effect keyed on the
+ // refs alone would run against `null` and never re-attach the observer.
+ React.useEffect(() => {
+ if (!contentReady) return;
+ const content = contentRef.current;
+ const element = bodyRef.current;
+ if (element && stickToBottomRef.current) {
+ element.scrollTop = element.scrollHeight;
+ }
+ if (!content || typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver(() => {
+ const body = bodyRef.current;
+ if (body && stickToBottomRef.current) {
+ body.scrollTop = body.scrollHeight;
+ }
+ });
+ observer.observe(content);
+ return () => observer.disconnect();
+ }, [bodyRef, contentReady, contentRef]);
+ return React.useCallback((event: React.UIEvent) => {
+ const element = event.currentTarget;
+ stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
+ }, []);
+};
+
+const BtwFrame: React.FC<{
+ title: string;
+ actions?: React.ReactNode;
+ onTitleClick?: () => void;
+ titleClickLabel?: string;
+ collapsed?: boolean;
+ headerSpinner?: boolean;
+ children?: React.ReactNode;
+}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
+