diff --git a/.agents/skills/communication-style/SKILL.md b/.agents/skills/communication-style/SKILL.md
new file mode 100644
index 00000000..d1760fb8
--- /dev/null
+++ b/.agents/skills/communication-style/SKILL.md
@@ -0,0 +1,81 @@
+---
+name: communication-style
+description: Use it always.
+author: poteto (pstack)
+---
+
+# Communication style
+
+Edit text to remove AI patterns and add human voice.
+
+## Process
+
+1. Scan for the patterns below.
+2. Rewrite. Preserve meaning, match intended tone.
+3. Add soul (see next section).
+4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells.
+
+## Adding soul
+
+Removing patterns is half the job. Sterile, voiceless writing is just as obvious.
+
+- **Have opinions.** React to facts instead of neutrally listing pros and cons.
+- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up.
+- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive."
+- **Use "I" when it fits.** First person isn't unprofessional.
+- **Let some mess in.** Perfect structure looks machine-made.
+- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am."
+
+## Patterns to detect and fix
+
+### Content
+
+1. **Puffery.** "pivotal moment", "testament to", "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened.
+2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said.
+3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources.
+4. **Promotional language.** "nestled", "vibrant", "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions.
+5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete.
+6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts.
+
+### Language
+
+7. **AI vocabulary.** Additionally, crucial, delve, enduring, enhance, fostering, garner, interplay, intricate, landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore, vibrant. Replace with plain words.
+8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has".
+9. **"Not just X, but Y."** State the point directly instead.
+10. **Rule of three.** Forcing ideas into groups of three. Use the natural number.
+11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it.
+12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly.
+
+### Style
+
+13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma.
+14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation.
+15. **Boldface overuse.** Don't bold every proper noun or acronym.
+16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine.
+17. **Title case headings.** Use sentence case.
+18. **Decorative emojis.** Remove from headings and bullets.
+19. **Curly quotes.** Replace with straight quotes.
+
+### Communication artifacts
+
+20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
+21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
+22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
+
+### Filler
+
+23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
+24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
+25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
+
+### Jargon
+
+26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
+
+### Plain speech
+
+27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write it. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
+28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
+29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
+30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
+31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
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/AGENTS.md b/AGENTS.md
index 0ea1ebb6..9d2a437d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,6 +79,10 @@ task-required reference named by those skills. Skills are canonical for their
detailed workflows and checklists. Treating this table as optional advice is a
process violation.
+**Always load `.agents/skills/communication-style/SKILL.md` at the start of
+every task, before any analysis, tool call, or response. Apply its guidance to
+all messages and written output, not only to user-facing copy or documentation.**
+
| Trigger | Required skill |
|---|---|
| Source/dependency changes, exports or package contracts, build/generated assets, or module ownership | `openchamber-change-discipline` |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c9a9353..38d653c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,42 @@ 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
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
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 dc2f7923..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",
@@ -97,7 +97,7 @@
},
"packages/electron": {
"name": "@openchamber/electron",
- "version": "1.18.4",
+ "version": "1.19.0",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
@@ -134,7 +134,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
- "version": "1.18.4",
+ "version": "1.19.0",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0",
@@ -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",
@@ -238,10 +238,10 @@
},
"packages/vscode": {
"name": "openchamber",
- "version": "1.18.4",
+ "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",
@@ -261,16 +261,15 @@
},
"packages/web": {
"name": "@openchamber/web",
- "version": "1.18.4",
+ "version": "1.19.0",
"bin": {
"openchamber": "./bin/cli.js",
},
"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",
- "adm-zip": "^0.6.0",
"bun-pty": "^0.4.5",
"compression": "^1.8.1",
"cron-parser": "^4.9.0",
@@ -306,7 +305,6 @@
"@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0",
"@tailwindcss/postcss": "^4.0.0",
- "@types/adm-zip": "^0.5.7",
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
@@ -1005,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 704434b4..6e4cd2b6 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",
@@ -115,7 +115,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/de/skills-catalog.mdx b/packages/docs/content/docs/de/skills-catalog.mdx
index afb5f31e..7026837b 100644
--- a/packages/docs/content/docs/de/skills-catalog.mdx
+++ b/packages/docs/content/docs/de/skills-catalog.mdx
@@ -12,7 +12,7 @@ Zum Schreiben eigener Skills siehe [Skills](/skills/).
## Einen Skill installieren
1. Öffne den Katalog.
-2. Durchsuche die eingebauten Quellen — das Anthropic-Skills-Repo und die ClawHub-Community-Registry — oder nutze die Suche.
+2. Durchsuche die eingebauten Quellen — wie das Anthropic-Skills-Repo — oder nutze die Suche.
3. Wähle einen Skill aus und installiere ihn.
4. Entscheide, wo er installiert werden soll: für alles, was du tust, oder nur für das aktuelle Projekt.
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/es/skills-catalog.mdx b/packages/docs/content/docs/es/skills-catalog.mdx
index eadec080..c29ffb71 100644
--- a/packages/docs/content/docs/es/skills-catalog.mdx
+++ b/packages/docs/content/docs/es/skills-catalog.mdx
@@ -12,7 +12,7 @@ Para escribir tus propias skills, consulta [Skills](/es/skills/).
## Instala una skill
1. Abre el catálogo.
-2. Explora las fuentes integradas —el repositorio de skills de Anthropic y el registro comunitario de ClawHub— o busca.
+2. Explora las fuentes integradas —como el repositorio de skills de Anthropic— o busca.
3. Elige una skill e instálala.
4. Elige dónde instalarla: para todo lo que hagas, o solo en el proyecto actual.
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/fr/skills-catalog.mdx b/packages/docs/content/docs/fr/skills-catalog.mdx
index 72123162..e6eb2323 100644
--- a/packages/docs/content/docs/fr/skills-catalog.mdx
+++ b/packages/docs/content/docs/fr/skills-catalog.mdx
@@ -12,7 +12,7 @@ Pour écrire vos propres skills, voir [Skills](/skills/).
## Installer un skill
1. Ouvrez le catalogue.
-2. Parcourez les sources intégrées — le dépôt de skills Anthropic et le registre communautaire ClawHub — ou lancez une recherche.
+2. Parcourez les sources intégrées — comme le dépôt de skills Anthropic — ou lancez une recherche.
3. Choisissez un skill et installez-le.
4. Choisissez où l’installer : pour tout ce que vous faites, ou seulement pour le projet actuel.
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/ja/skills-catalog.mdx b/packages/docs/content/docs/ja/skills-catalog.mdx
index 8991f05f..029a0882 100644
--- a/packages/docs/content/docs/ja/skills-catalog.mdx
+++ b/packages/docs/content/docs/ja/skills-catalog.mdx
@@ -12,7 +12,7 @@ Skills Catalog では、自分で書く代わりに、他の人が公開した
## スキルをインストールする
1. カタログを開きます。
-2. 組み込みソース(Anthropic skills repo と ClawHub community registry)を閲覧するか、検索します。
+2. 組み込みソース(Anthropic skills repo など)を閲覧するか、検索します。
3. スキルを選び、インストールします。
4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。
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/ko/skills-catalog.mdx b/packages/docs/content/docs/ko/skills-catalog.mdx
index ba16461a..d2cbb407 100644
--- a/packages/docs/content/docs/ko/skills-catalog.mdx
+++ b/packages/docs/content/docs/ko/skills-catalog.mdx
@@ -12,7 +12,7 @@ Skills Catalog를 사용하면 직접 작성하는 대신 다른 사람이 게
## 스킬 설치하기
1. 카탈로그를 엽니다.
-2. 내장된 소스(Anthropic 스킬 저장소와 ClawHub 커뮤니티 레지스트리)를 둘러보거나 검색합니다.
+2. 내장된 소스(예: Anthropic 스킬 저장소)를 둘러보거나 검색합니다.
3. 스킬을 선택하고 설치합니다.
4. 설치 위치를 선택합니다. 모든 작업에 적용할지, 현재 프로젝트에만 적용할지 선택합니다.
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/pl/skills-catalog.mdx b/packages/docs/content/docs/pl/skills-catalog.mdx
index d3544fa1..d38a842f 100644
--- a/packages/docs/content/docs/pl/skills-catalog.mdx
+++ b/packages/docs/content/docs/pl/skills-catalog.mdx
@@ -12,7 +12,7 @@ Aby pisać własne skille, zobacz [Skille](/pl/skills/).
## Zainstaluj skill
1. Otwórz katalog.
-2. Przeglądaj wbudowane źródła — repozytorium skilli Anthropic oraz rejestr społeczności ClawHub — albo wyszukaj.
+2. Przeglądaj wbudowane źródła — na przykład repozytorium skilli Anthropic — albo wyszukaj.
3. Wybierz skill i zainstaluj go.
4. Wybierz, gdzie go zainstalować: dla wszystkiego, co robisz, albo tylko dla bieżącego projektu.
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/pt-br/skills-catalog.mdx b/packages/docs/content/docs/pt-br/skills-catalog.mdx
index eea841f4..b28c44f3 100644
--- a/packages/docs/content/docs/pt-br/skills-catalog.mdx
+++ b/packages/docs/content/docs/pt-br/skills-catalog.mdx
@@ -12,7 +12,7 @@ Para escrever suas próprias skills, veja [Skills](/pt-br/skills/).
## Instalar uma skill
1. Abra o catálogo.
-2. Navegue pelas fontes integradas — o repositório de skills da Anthropic e o registro comunitário ClawHub — ou pesquise.
+2. Navegue pelas fontes integradas — como o repositório de skills da Anthropic — ou pesquise.
3. Escolha uma skill e instale-a.
4. Escolha onde instalá-la: para tudo o que você faz, ou apenas no projeto atual.
diff --git a/packages/docs/content/docs/skills-catalog.mdx b/packages/docs/content/docs/skills-catalog.mdx
index 9ddcb131..e223a459 100644
--- a/packages/docs/content/docs/skills-catalog.mdx
+++ b/packages/docs/content/docs/skills-catalog.mdx
@@ -12,7 +12,7 @@ For writing your own skills, see [Skills](/skills/).
## Install a skill
1. Open the catalog.
-2. Browse the built-in sources — the Anthropic skills repo and the ClawHub community registry — or search.
+2. Browse the built-in sources — like the Anthropic skills repo — or search.
3. Pick a skill and install it.
4. Choose where to install it: for everything you do, or just the current project.
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/uk/skills-catalog.mdx b/packages/docs/content/docs/uk/skills-catalog.mdx
index 78c0c1d6..585ad77a 100644
--- a/packages/docs/content/docs/uk/skills-catalog.mdx
+++ b/packages/docs/content/docs/uk/skills-catalog.mdx
@@ -12,7 +12,7 @@ description: Переглядайте та встановлюйте готові
## Встановлення навички
1. Відкрийте каталог.
-2. Перегляньте вбудовані джерела — репозиторій навичок Anthropic та спільнотний реєстр ClawHub — або скористайтеся пошуком.
+2. Перегляньте вбудовані джерела — наприклад репозиторій навичок Anthropic — або скористайтеся пошуком.
3. Оберіть навичку й установіть її.
4. Виберіть, куди встановити: для всього, що ви робите, чи лише для поточного проєкту.
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/docs/content/docs/zh-cn/skills-catalog.mdx b/packages/docs/content/docs/zh-cn/skills-catalog.mdx
index f393944e..a3d982b1 100644
--- a/packages/docs/content/docs/zh-cn/skills-catalog.mdx
+++ b/packages/docs/content/docs/zh-cn/skills-catalog.mdx
@@ -12,7 +12,7 @@ Skills 目录让你能够安装其他人发布的 skill,而不必自己编写
## 安装 skill
1. 打开目录。
-2. 浏览内置来源 — Anthropic skills 仓库和 ClawHub 社区注册表 — 或进行搜索。
+2. 浏览内置来源 — 例如 Anthropic skills 仓库 — 或进行搜索。
3. 选择一个 skill 并安装它。
4. 选择安装位置:用于你的所有工作,或仅用于当前项目。
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 }) => (
+