diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml
index 655b666595..be7aba8b99 100644
--- a/.github/workflows/oc-review.yml
+++ b/.github/workflows/oc-review.yml
@@ -31,3 +31,10 @@ jobs:
- name: Lint
run: bun run lint
+
+ - name: Electron Linux packaging unit tests
+ working-directory: packages/electron
+ run: |
+ bun run test:architecture
+ bun run test:updater
+ bun run type-check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e694069189..4bc1b31f62 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -359,6 +359,148 @@ jobs:
path: packages/electron/dist/latest.yml
retention-days: 1
+ build-desktop-electron-linux:
+ needs: create-release
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ arch: x64
+ host_arch: x86_64
+ artifact_arch: x86_64
+ manifest: latest-linux.yml
+ - runner: ubuntu-24.04-arm
+ arch: arm64
+ host_arch: aarch64
+ artifact_arch: arm64
+ manifest: latest-linux-arm64.yml
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - name: Setup bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: '22'
+
+ - name: Verify native Linux architecture
+ env:
+ EXPECTED_HOST_ARCH: ${{ matrix.host_arch }}
+ OPENCHAMBER_TARGET_ARCH: ${{ matrix.arch }}
+ run: |
+ set -euo pipefail
+ test "$(uname -m)" = "$EXPECTED_HOST_ARCH"
+ test "$(node -p 'process.arch')" = "$OPENCHAMBER_TARGET_ARCH"
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Get bundled OpenCode CLI version
+ id: opencode_cli_version
+ shell: bash
+ run: |
+ VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Cache bundled OpenCode CLI artifact
+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
+ with:
+ path: packages/electron/.cache/opencode-cli
+ key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
+ restore-keys: |
+ opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
+
+ - name: Run focused Electron release tests
+ working-directory: packages/electron
+ run: |
+ bun run test:architecture
+ bun run test:updater
+
+ - name: Build and package Linux AppImage
+ working-directory: packages/electron
+ env:
+ OPENCHAMBER_TARGET_ARCH: ${{ matrix.arch }}
+ run: |
+ set -euo pipefail
+ bun run build:web-assets
+ bun run prepare:opencode-cli
+ bun run verify:opencode-cli
+ bun run bundle:main
+ bun run rebuild:native
+ node ./scripts/package.mjs --linux --${{ matrix.arch }} --publish=never
+ bun run verify:opencode-cli:packaged
+ bun run verify:linux-appimage
+
+ - name: Validate Linux update manifest
+ working-directory: packages/electron
+ env:
+ VERSION: ${{ needs.create-release.outputs.version }}
+ ARTIFACT_ARCH: ${{ matrix.artifact_arch }}
+ MANIFEST: ${{ matrix.manifest }}
+ run: |
+ set -euo pipefail
+ APPIMAGE="dist/OpenChamber-${VERSION}-linux-${ARTIFACT_ARCH}.AppImage"
+ node ./scripts/verify-update-manifest.mjs "dist/${MANIFEST}" "$APPIMAGE" "$VERSION"
+
+ - name: Upload validated Linux release files
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: linux-release-${{ matrix.arch }}
+ path: |
+ packages/electron/dist/OpenChamber-${{ needs.create-release.outputs.version }}-linux-${{ matrix.artifact_arch }}.AppImage
+ packages/electron/dist/${{ matrix.manifest }}
+ if-no-files-found: error
+ retention-days: 1
+
+ publish-electron-linux:
+ needs: [create-release, build-desktop-electron-linux]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - name: Download x64 Linux release files
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ name: linux-release-x64
+ path: artifacts/x64
+
+ - name: Download arm64 Linux release files
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ name: linux-release-arm64
+ path: artifacts/arm64
+
+ - name: Revalidate separate Linux manifests
+ env:
+ VERSION: ${{ needs.create-release.outputs.version }}
+ run: |
+ set -euo pipefail
+ node packages/electron/scripts/verify-update-manifest.mjs \
+ artifacts/x64/latest-linux.yml \
+ "artifacts/x64/OpenChamber-${VERSION}-linux-x86_64.AppImage" \
+ "$VERSION"
+ node packages/electron/scripts/verify-update-manifest.mjs \
+ artifacts/arm64/latest-linux-arm64.yml \
+ "artifacts/arm64/OpenChamber-${VERSION}-linux-arm64.AppImage" \
+ "$VERSION"
+
+ - name: Upload Linux AppImages and manifests to release
+ if: ${{ github.event.inputs.dry_run != 'true' }}
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
+ with:
+ tag_name: v${{ needs.create-release.outputs.version }}
+ files: |
+ artifacts/x64/OpenChamber-${{ needs.create-release.outputs.version }}-linux-x86_64.AppImage
+ artifacts/x64/latest-linux.yml
+ artifacts/arm64/OpenChamber-${{ needs.create-release.outputs.version }}-linux-arm64.AppImage
+ artifacts/arm64/latest-linux-arm64.yml
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
combine-electron-manifests:
needs: [create-release, build-desktop-electron-macos]
runs-on: ubuntu-latest
@@ -404,12 +546,47 @@ jobs:
secrets: inherit
finalize-release:
- needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, publish-npm, combine-electron-manifests, mobile-release]
+ needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, build-desktop-electron-linux, publish-electron-linux, publish-npm, combine-electron-manifests, mobile-release]
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }}
steps:
+ - name: Verify final Linux release asset inventory
+ if: ${{ github.event.inputs.dry_run != 'true' }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPOSITORY: ${{ github.repository }}
+ VERSION: ${{ needs.create-release.outputs.version }}
+ run: |
+ node - <<'NODE'
+ (async () => {
+ const { REPOSITORY: repo, VERSION: version, GITHUB_TOKEN: token } = process.env;
+ const expected = [
+ `OpenChamber-${version}-linux-x86_64.AppImage`,
+ 'latest-linux.yml',
+ `OpenChamber-${version}-linux-arm64.AppImage`,
+ 'latest-linux-arm64.yml',
+ ];
+ const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/v${version}`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
+ });
+ if (!response.ok) throw new Error(`Failed to inspect release assets: ${response.status} ${await response.text()}`);
+ const release = await response.json();
+ for (const name of expected) {
+ const matches = release.assets.filter((asset) => asset.name === name);
+ if (matches.length !== 1) throw new Error(`Expected exactly one ${name} release asset, found ${matches.length}`);
+ if (!Number.isSafeInteger(matches[0].size) || matches[0].size <= 0) {
+ throw new Error(`Release asset ${name} has invalid size ${matches[0].size}`);
+ }
+ }
+ console.log(`Verified ${expected.length} Linux release assets and both architecture manifests.`);
+ })().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+ NODE
+
- name: Publish release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
diff --git a/AGENTS.md b/AGENTS.md
index 14da190107..82482d4c5d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -91,6 +91,12 @@ Notification message preparation utilities for system notifications, including t
- Module docs: `packages/web/server/lib/notifications/DOCUMENTATION.md`
+##### permission-auto-accept
+
+Persistent server-owned permission auto-accept policy, subagent inheritance, retries, and reconnect reconciliation.
+
+- Module docs: `packages/web/server/lib/permission-auto-accept/DOCUMENTATION.md`
+
##### scheduled-tasks
Scheduled task persistence, execution, and event fanout for recurring sessions.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc272ad988..67a74e1fba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+- **Desktop/Linux:** official AppImage releases for x86_64 and arm64, with in-app window controls (position follows OS defaults or Settings → Sessions), writable-AppImage auto-update, and clearer updater errors when the AppImage is missing or read-only. Linux does not yet include system tray or launch-at-login.
+
+## [1.16.0] - 2026-07-13
+
+- **Session goals:** arm the new target button in the composer and your next prompt becomes a [goal](https://docs.openchamber.dev/session-goals/) — the session keeps working toward it on its own, with an independent small-model audit checking each finished turn, until the objective is verifiably complete, blocked, or over its optional token budget. The loop runs on the server, so it continues with the app closed and survives restarts. A goal strip above the composer shows progress with pause/resume; goals can also start from the plan-implement dialog, from scheduled tasks ("Run as goal"), or with the new "Craft a Goal" starter and `/craft-goal` command. While a goal runs, per-turn "ready" notifications are replaced by a single notification when it settles.
+- **Usage:** OpenCode Go usage tracking is here, and Codex quota windows now show the correct reset times.
+- **Remote access:** connecting over the relay got much faster — the app no longer waits for a stale local address to time out before trying the relay (previously up to ~20 seconds on a phone away from home). When your computer gets a new local IP, paired devices now learn the new address over the relay and quietly move back to the local network on their own — no re-pairing. The phone's launch screen shows which device it is connecting to.
+- Remote access: running several OpenChamber instances on the same machine no longer makes paired devices land on a random one of them — only one process per machine serves the relay now. This was behind intermittent "Unable to reach server" errors on paired phones.
+- Permissions: per-session auto-accept now lives on the server — sessions keep auto-accepting tool calls while the app is closed and after a server restart, subagent sessions inherit the setting, and it can be enabled on a draft before the first message (thanks to @bashrusakh for the draft fix).
+- Chat: subagent sessions can now be prompted directly — open a subagent from the context panel and send it follow-up messages (off by default, available in settings).
+- Chat: queued messages now send when the session is already idle instead of waiting forever in some cases, pending agent questions stay answerable after a server restart, and session renames no longer flicker back to the old title (thanks to @bashrusakh).
+- Files: the file viewer has a markdown preview toggle (thanks to @greghaynes).
+- Sidebar: projects can be sorted by different modes with a direction toggle, pinned sessions survive refreshes, and the file tree stays expanded while it refreshes (thanks to @bashrusakh).
+- Command palette: projects are included in the fuzzy search alongside sessions and files (thanks to @bashrusakh).
+- Settings: chat visual settings are grouped into labeled sections, and a new editor font size setting for the code editor (thanks to @bashrusakh).
+- GitHub: PR and issue context now resolves against the source repository in fork workflows (thanks to @bashrusakh).
+- Agents: saving agent settings from the UI no longer drops custom YAML frontmatter fields (thanks to @bashrusakh).
+- Notifications: session errors and subagent completions now notify reliably across desktop, web, and mobile.
+- Editor: "Open in" now recognizes VS Code Insiders.
+- Windows: paths no longer mismatch on drive letter casing, which could split one project into duplicates (thanks to @bashrusakh).
+- Mobile: the sessions sidebar opens instantly instead of taking many seconds on some devices (thanks to @tomzx).
+- Mobile: renaming a saved instance no longer breaks its connection — the stored access token was getting lost on edit.
+- Mobile: on Android 15 the app no longer draws under the status bar.
+- Security: requests that spoof local host headers to look like same-machine traffic are rejected.
+
## [1.15.0] - 2026-07-10
- **Remote access:** a new [private relay](https://docs.openchamber.dev/private-relay/) lets you reach your instance from anywhere — no open ports and no third-party tunnel, over an end-to-end-encrypted tunnel. It turns on by itself when you pair a device over it and turns off once no paired device uses it (thanks to @yulia-ivashko).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5a3d6c72ba..239722485d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,7 +3,7 @@
## Getting Started
```bash
-git clone https://github.com/btriapitsyn/openchamber.git
+git clone https://github.com/openchamber/openchamber.git
cd openchamber
bun install
```
@@ -31,12 +31,14 @@ bun run electron:dev:bundled # Electron shell using built web assets
bun run electron:build # Package desktop app for the current platform
```
-Desktop supports macOS and Windows. The build output is written to `packages/electron/dist`.
+Desktop supports macOS, Windows, and Linux. The build output is written to `packages/electron/dist`.
macOS builds create `dmg` and `zip` files. You need Xcode/build tools for notarized packaging and icon asset work.
Windows builds create an NSIS installer. If signing env vars are not set, the build script makes an unsigned installer.
+Linux builds produce an AppImage for the native x64 or arm64 host.
+
For desktop-specific details, see [`packages/electron/README.md`](./packages/electron/README.md).
### VS Code Extension
@@ -94,7 +96,17 @@ Windows:
bun run electron:build
```
-Linux is supported for web/CLI development. A Linux desktop app is still planned, so Electron packaging is mainly macOS and Windows right now.
+Linux x64 and arm64 AppImages are packaged natively on the matching host architecture. Use Bun for dependency installation and packaging orchestration:
+
+```bash
+OPENCHAMBER_TARGET_ARCH=x64 bun run electron:build
+# On an arm64 host:
+OPENCHAMBER_TARGET_ARCH=arm64 bun run electron:build
+
+bun run --cwd packages/electron verify:linux-appimage
+```
+
+The final AppImage verifier checks desktop identity and the architecture of Electron, the bundled OpenCode CLI, and packaged native modules.
## Before Submitting
@@ -149,4 +161,4 @@ You can still help:
## Questions?
-Open an [issue](https://github.com/btriapitsyn/openchamber/issues) or ask in [Discord](https://discord.gg/ZYRSdnwwKA).
+Open an [issue](https://github.com/openchamber/openchamber/issues) or ask in [Discord](https://discord.gg/ZYRSdnwwKA).
diff --git a/README.md b/README.md
index f01b25bea1..9c21c4873f 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# OpenChamber
-[](https://github.com/btriapitsyn/openchamber/stargazers)
-[](https://github.com/btriapitsyn/openchamber/releases/latest)
+[](https://github.com/openchamber/openchamber/stargazers)
+[](https://github.com/openchamber/openchamber/releases/latest)
[](https://opencode.ai)
[](https://discord.gg/ZYRSdnwwKA)
[](https://ko-fi.com/G2G41SAWNS)
@@ -57,7 +57,7 @@
- Background notifications plus reliable cross-tab session activity tracking
- Built-in self-update + restart flow that keeps your server settings intact
-### Desktop (macOS + Windows)
+### Desktop (macOS + Windows + Linux)
- Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal
- Multiple native windows for separate projects or sessions
@@ -88,8 +88,24 @@
> **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai).
-### **Desktop (macOS + Windows)**
-Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
+### **Desktop (macOS + Windows + Linux)**
+
+Download the latest Desktop release from [GitHub Releases](https://github.com/openchamber/openchamber/releases).
+
+On Linux, choose the AppImage for your system:
+
+- `linux-x86_64.AppImage` for 64-bit Intel or AMD systems
+- `linux-arm64.AppImage` for ARM64/aarch64 systems
+
+Make the AppImage executable before launching it, for example with `chmod +x `. Keep the AppImage in a location your user can write to so OpenChamber can download and apply in-app updates.
+
+Linux AppImages need FUSE (`libfuse.so.2`). On Ubuntu/Debian install `libfuse2` (or `fuse` / `libfuse2t64` on newer releases). If FUSE is unavailable, run with extraction instead:
+
+```bash
+APPIMAGE_EXTRACT_AND_RUN=1 ./OpenChamber-*-linux-*.AppImage
+```
+
+Linux Desktop ships as AppImage with in-app window controls and auto-update when running from a writable AppImage. System tray and launch-at-login are not available on Linux yet (macOS/Windows only).
### **VS Code**
Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions.
@@ -98,7 +114,7 @@ Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=f
_requires Node.js 22+_
```bash
-curl -fsSL https://raw.githubusercontent.com/btriapitsyn/openchamber/main/scripts/install.sh | bash
+curl -fsSL https://raw.githubusercontent.com/openchamber/openchamber/main/scripts/install.sh | bash
openchamber --ui-password be-creative-here
```
@@ -352,7 +368,7 @@ chown -R 1000:1000 data/
-Desktop (macOS + Windows)
+Desktop (macOS + Windows + Linux)
- Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal
- Multiple native windows for separate projects or sessions
@@ -406,7 +422,6 @@ chown -R 1000:1000 data/
Active development. Here's what's being worked on or planned:
-- Linux desktop app
- Mobile app with remote instance and laptop connectivity
- More built-in tunneling options
- Kanban board for multi-agent management - keeping the human in the loop and in control
diff --git a/package.json b/package.json
index f8870ec7ac..dd9493b102 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openchamber-monorepo",
- "version": "1.15.0",
+ "version": "1.16.0",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true,
"type": "module",
diff --git a/packages/docs/content/docs/es/scheduled-tasks.mdx b/packages/docs/content/docs/es/scheduled-tasks.mdx
index b7fc2e4e09..e199dc4950 100644
--- a/packages/docs/content/docs/es/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/es/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ Una tarea programada ejecuta un prompt por ti según una programación; por ejem
Puedes ejecutar cualquier tarea de inmediato con **run now** para comprobar que hace lo que esperas.
+Marca **Ejecutar como objetivo** para que la ejecución persiga su prompt hasta completarlo en lugar de detenerse tras una respuesta — consulta [Objetivos de sesión](/session-goals/).
+
## Cómo se ve el éxito
Después de una ejecución, la tarea muestra cuándo se ejecutó por última vez, si tuvo éxito y un enlace a la sesión que creó. Si una ejecución falla, el error también se muestra ahí.
diff --git a/packages/docs/content/docs/es/session-goals.mdx b/packages/docs/content/docs/es/session-goals.mdx
new file mode 100644
index 0000000000..ee93021def
--- /dev/null
+++ b/packages/docs/content/docs/es/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: Objetivos de sesión
+description: Convierte un prompt en un objetivo hacia el que el agente trabaja automáticamente.
+---
+
+# Objetivos de sesión
+
+Un objetivo convierte un solo prompt en una línea de meta. En lugar de empujar al agente con "continúa" tras cada respuesta, defines el objetivo una vez — y OpenChamber mantiene la sesión trabajando hacia él automáticamente, comprobando el progreso con un auditor independiente después de cada turno. Sigue funcionando incluso mientras no estás.
+
+## Iniciar un objetivo
+
+1. Pulsa el botón de diana en el compositor. Se ilumina — el modo objetivo está armado.
+2. Escribe tu prompt y envíalo. Ese mensaje se convierte en el objetivo.
+
+Funciona igual en una sesión existente y en un borrador de sesión nueva: arma la diana, escribe el primer mensaje, envía — la nueva sesión arranca con el objetivo ya activo.
+
+### Más formas de iniciar un objetivo
+
+- **Desde una respuesta del agente**: en el diálogo "Start new session from this answer", marca **Ejecutar como objetivo** — la respuesta se entrega como una tarea que la nueva sesión ejecuta hasta completarla (combínalo con **Create worktree** para una ejecución aislada).
+- **Desde un plan**: al implementar un plan guardado en una sesión o worktree nuevos, marca **Ejecutar como objetivo** en el diálogo. El objetivo lleva el contenido del plan, así que el auditor juzga el progreso contra el plan real.
+- **Según un horario**: marca **Ejecutar como objetivo** en una [tarea programada](/scheduled-tasks/) para que las ejecuciones recurrentes persigan su prompt hasta completarlo.
+
+## Escribe un objetivo autocontenido
+
+El auditor de progreso solo ve tu objetivo y la última respuesta del agente — no el historial del chat. Redacta el mensaje-objetivo de forma que alguien sin el contexto de la conversación entienda cómo es el estado final.
+
+- Bien: "Añade tests para el módulo de exportación y haz que toda la suite pase."
+- No tan bien: "Arréglalo" o "Continúa con esa idea."
+
+Para pequeños ajustes contextuales no necesitas un objetivo — envía un mensaje normal.
+
+## Cómo funciona
+
+Cuando el agente se detiene y la sesión queda en silencio un momento, OpenChamber:
+
+1. Pide a un modelo pequeño y barato que audite el último turno contra el objetivo: ¿seguir, hecho o atascado?
+2. Si el veredicto es "seguir", envía un prompt de continuación y el agente retoma el trabajo.
+3. Si el objetivo se ha logrado de forma verificable, el objetivo se completa y recibes una notificación.
+4. Si el agente está realmente atascado (necesita tu intervención), el objetivo se detiene como bloqueado — pero solo después de que el auditor lo diga tres veces seguidas, así que un tropiezo puntual nunca termina el objetivo.
+
+También hay topes de seguridad: un presupuesto de tokens opcional, un límite de continuaciones automáticas y una parada ante errores de turno. Si el contexto de la sesión se compacta a mitad del trabajo, el objetivo simplemente continúa — chocar con la ventana de contexto es prueba de que el trabajo no había terminado.
+
+### Detener y reanudar
+
+- El **botón de detener** aborta el turno en curso y pausa el objetivo — tu "para" explícito siempre gana al bucle.
+- **Pausar** en la franja del objetivo hace lo mismo desde el otro lado: pausa el objetivo y detiene el turno en curso.
+- Mientras está pausado, chatea con normalidad — el bucle no interfiere.
+- **Reanudar** rearma el bucle: en una sesión inactiva el empujón de continuación sale de inmediato; si el agente está trabajando, el bucle se reengancha en su siguiente pausa.
+
+## Observar y gestionar
+
+- La franja sobre el compositor muestra la última nota de progreso, el estado y el uso de tokens, con un botón de pausar/reanudar integrado. Cuando el agente se ha detenido y el objetivo sigue activo, la franja muestra un **Evaluando…** giratorio — es la ventana de silencio y la auditoría en marcha.
+- El botón de diana permanece encendido mientras el objetivo corre (azul), se vuelve verde al completarse y rojo cuando está bloqueado o sin presupuesto. Púlsalo para abrir el diálogo del objetivo: edita el objetivo o el presupuesto, o elimínalo. Un objetivo completado es de solo lectura — elimínalo y arma uno nuevo.
+- En la barra lateral de sesiones aparece una pequeña diana junto a la fecha de la sesión, coloreada según el estado del objetivo.
+
+## Notificaciones
+
+Mientras un objetivo está activo, las notificaciones por turno de "agente listo" se suprimen — solo harían eco de las continuaciones del propio bucle. Cuando el objetivo se resuelve (completado, bloqueado o presupuesto alcanzado) recibes una única notificación final, en el escritorio y como push móvil. Obedece el mismo ajuste de "notificar al completar"; las solicitudes de permisos, las preguntas y las notificaciones de error siguen funcionando con normalidad.
+
+## Presupuesto de tokens
+
+En **Ajustes → Chat → Objetivo** puedes definir un presupuesto de tokens predeterminado para nuevos objetivos. Al alcanzarlo, el objetivo se detiene como "presupuesto alcanzado" en lugar de gastar más — puedes subir el presupuesto y reanudar desde el diálogo del objetivo.
+
+## Ten en cuenta
+
+- El bucle del objetivo corre en el servidor de OpenChamber, no en tu pestaña del navegador. Cierra la pestaña, bloquea el teléfono — el agente sigue trabajando y recibirás una notificación cuando el objetivo termine. El servidor (app de escritorio o proceso `openchamber`) debe seguir en marcha.
+- Los objetivos usan el proveedor y modelo de tu propia sesión, incluidas las llamadas del auditor — nada sale hacia proveedores que no uses ya.
+- Un objetivo por sesión a la vez.
+
+## Relacionado
+
+- [Tareas programadas](/scheduled-tasks/) — ejecutar un prompt según un horario; activa allí "Ejecutar como objetivo" para que la ejecución programada persiga su prompt hasta completarlo
+- [Notificaciones](/notifications/) — cómo te enteras de un objetivo terminado
diff --git a/packages/docs/content/docs/fr/scheduled-tasks.mdx b/packages/docs/content/docs/fr/scheduled-tasks.mdx
index 765141dd9e..379a3d0fa3 100644
--- a/packages/docs/content/docs/fr/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/fr/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ Une tâche planifiée lance un prompt pour vous selon un planning — par exempl
Vous pouvez lancer n’importe quelle tâche immédiatement avec **run now** pour vérifier qu’elle fait ce que vous attendez.
+Cochez **Exécuter comme objectif** pour que l'exécution poursuive son prompt jusqu'au bout au lieu de s'arrêter après une réponse — voir [Objectifs de session](/session-goals/).
+
## À quoi ressemble une réussite
Après une exécution, la tâche indique quand elle a tourné pour la dernière fois, si elle a réussi et un lien vers la session créée. Si une exécution échoue, l’erreur s’affiche aussi à cet endroit.
diff --git a/packages/docs/content/docs/fr/session-goals.mdx b/packages/docs/content/docs/fr/session-goals.mdx
new file mode 100644
index 0000000000..74816bca61
--- /dev/null
+++ b/packages/docs/content/docs/fr/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: Objectifs de session
+description: Transformez un prompt en objectif vers lequel l'agent travaille automatiquement.
+---
+
+# Objectifs de session
+
+Un objectif transforme un seul prompt en ligne d'arrivée. Au lieu de relancer l'agent avec « continue » après chaque réponse, vous définissez l'objectif une fois — et OpenChamber fait travailler la session vers lui automatiquement, en vérifiant la progression avec un auditeur indépendant après chaque tour. Le travail continue même en votre absence.
+
+## Démarrer un objectif
+
+1. Appuyez sur le bouton cible du composeur. Il s'allume — le mode objectif est armé.
+2. Écrivez votre prompt et envoyez-le. Ce message devient l'objectif.
+
+Cela fonctionne aussi bien dans une session existante que dans un brouillon de nouvelle session : armez la cible, écrivez le premier message, envoyez — la nouvelle session démarre avec l'objectif déjà actif.
+
+### D'autres façons de démarrer un objectif
+
+- **Depuis une réponse de l'agent** : dans le dialogue « Start new session from this answer », cochez **Exécuter comme objectif** — la réponse est transmise comme une mission que la nouvelle session exécute jusqu'au bout (combinez avec **Create worktree** pour une exécution isolée).
+- **Depuis un plan** : en implémentant un plan enregistré dans une nouvelle session ou un worktree, cochez **Exécuter comme objectif** dans le dialogue. L'objectif porte le contenu du plan, l'auditeur juge donc la progression par rapport au plan réel.
+- **Selon un horaire** : cochez **Exécuter comme objectif** sur une [tâche planifiée](/scheduled-tasks/) pour que les exécutions récurrentes poursuivent leur prompt jusqu'au bout.
+
+## Rédigez un objectif autonome
+
+L'auditeur de progression ne voit que votre objectif et la dernière réponse de l'agent — pas l'historique du chat. Formulez donc le message-objectif de sorte qu'une personne sans le contexte de la conversation comprenne à quoi ressemble l'état final.
+
+- Bien : « Ajoute des tests pour le module d'export et fais passer toute la suite de tests. »
+- Moins bien : « Corrige ça » ou « Continue sur cette idée. »
+
+Pour de petits ajustements contextuels, pas besoin d'objectif — envoyez un message normal.
+
+## Comment ça marche
+
+Quand l'agent s'arrête et que la session reste calme un instant, OpenChamber :
+
+1. Demande à un petit modèle économique d'auditer le dernier tour par rapport à l'objectif : continuer, terminé ou bloqué ?
+2. Si le verdict est « continuer », il envoie un prompt de continuation et l'agent reprend le travail.
+3. Si l'objectif est atteint de manière vérifiable, l'objectif se termine et vous recevez une notification.
+4. Si l'agent est réellement bloqué (il a besoin de vous), l'objectif s'arrête comme bloqué — mais seulement après que l'auditeur l'a dit trois fois de suite ; un accroc ponctuel ne termine jamais l'objectif.
+
+Il y a aussi des garde-fous : un budget de tokens optionnel, un plafond de continuations automatiques et un arrêt en cas d'erreur de tour. Si le contexte de la session est compacté en plein travail, l'objectif continue simplement — heurter la fenêtre de contexte prouve que le travail n'était pas fini.
+
+### Arrêter et reprendre
+
+- Le **bouton stop** interrompt le tour en cours et met l'objectif en pause — votre « stop » explicite l'emporte toujours sur la boucle.
+- **Pause** sur la bande de l'objectif fait la même chose dans l'autre sens : elle met l'objectif en pause et arrête le tour en cours.
+- Pendant la pause, discutez normalement — la boucle reste à l'écart.
+- **Reprendre** réarme la boucle : sur une session inactive, la relance part immédiatement ; si l'agent est en train de travailler, la boucle se raccroche silencieusement à sa prochaine pause.
+
+## Suivre et gérer
+
+- La bande au-dessus du composeur affiche la dernière note de progression, le statut et l'usage de tokens, avec un bouton pause/reprise intégré. Quand l'agent s'est arrêté et que l'objectif est actif, la bande affiche un **Évaluation…** animé — c'est la fenêtre de calme et l'audit en cours.
+- Le bouton cible reste allumé tant que l'objectif tourne (bleu), passe au vert à la fin et au rouge s'il est bloqué ou à court de budget. Appuyez dessus pour ouvrir le dialogue de l'objectif : modifier l'objectif ou le budget, ou le supprimer. Un objectif terminé est en lecture seule — supprimez-le, puis armez-en un nouveau.
+- Dans la barre latérale des sessions, une petite cible apparaît à côté de la date de la session, colorée selon l'état de l'objectif.
+
+## Notifications
+
+Tant qu'un objectif est actif, les notifications « agent prêt » à chaque tour sont supprimées — elles ne feraient qu'écho aux continuations de la boucle elle-même. Quand l'objectif se règle (terminé, bloqué ou budget atteint), vous recevez une seule notification finale, sur le bureau et en push mobile. Elle respecte le même réglage « notifier à la fin » ; les demandes de permission, les questions et les notifications d'erreur continuent de fonctionner normalement.
+
+## Budget de tokens
+
+Dans **Paramètres → Chat → Objectif**, vous pouvez définir un budget de tokens par défaut pour les nouveaux objectifs. Quand un objectif atteint son budget, il s'arrête en « budget atteint » au lieu de dépenser plus — vous pouvez augmenter le budget et reprendre depuis le dialogue de l'objectif.
+
+## À garder en tête
+
+- La boucle d'objectif tourne dans le serveur OpenChamber, pas dans votre onglet de navigateur. Fermez l'onglet, verrouillez le téléphone — l'agent continue, et vous recevez une notification quand l'objectif se termine. Le serveur (app de bureau ou processus `openchamber`) doit rester lancé.
+- Les objectifs utilisent le fournisseur et le modèle de votre propre session, y compris pour les appels de l'auditeur — rien ne part vers des fournisseurs que vous n'utilisez pas déjà.
+- Un objectif par session à la fois.
+
+## Voir aussi
+
+- [Tâches planifiées](/scheduled-tasks/) — exécuter un prompt selon un horaire ; activez-y « Exécuter comme objectif » pour qu'une exécution planifiée poursuive son prompt jusqu'au bout
+- [Notifications](/notifications/) — comment vous êtes prévenu d'un objectif terminé
diff --git a/packages/docs/content/docs/ja/scheduled-tasks.mdx b/packages/docs/content/docs/ja/scheduled-tasks.mdx
index a9b5301cb8..f6c7d6d31d 100644
--- a/packages/docs/content/docs/ja/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/ja/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ description: プロンプトをスケジュールに従って自動実行しま
任意のタスクは **run now** ですぐ実行でき、期待通り動くか確認できます。
+**ゴールとして実行**にチェックすると、実行は1回の返信で止まらず、プロンプトを完了まで追求します — [セッションゴール](/session-goals/)を参照してください。
+
## 成功時の見え方
実行後、タスクには最後に実行された時刻、成功したかどうか、作成されたセッションへのリンクが表示されます。実行に失敗した場合は、エラーもそこに表示されます。
diff --git a/packages/docs/content/docs/ja/session-goals.mdx b/packages/docs/content/docs/ja/session-goals.mdx
new file mode 100644
index 0000000000..329bdbd478
--- /dev/null
+++ b/packages/docs/content/docs/ja/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: セッションゴール
+description: プロンプトをゴールに変え、エージェントが自動的に取り組み続けます。
+---
+
+# セッションゴール
+
+ゴールは、1つのプロンプトをゴールラインに変えます。返信のたびに「続けて」と促す代わりに、ゴールを一度設定するだけ — OpenChamber がセッションを自動的にゴールへ向かわせ、各ターンの後に独立した監査モデルで進捗を確認します。離席中でも動き続けます。
+
+## ゴールを開始する
+
+1. コンポーザーのターゲットボタンを押します。点灯すればゴールモードが準備完了です。
+2. プロンプトを書いて送信します。そのメッセージがゴールの目標になります。
+
+既存のセッションでも新規セッションの下書きでも同じように機能します。ターゲットを有効にして最初のメッセージを書いて送信すれば、新しいセッションは最初からゴールが有効な状態で始まります。
+
+### ゴールを開始する他の方法
+
+- **エージェントの返信から**:「Start new session from this answer」ダイアログで **ゴールとして実行** にチェック — 返信が課題として引き継がれ、新しいセッションが完了まで実行します(**Create worktree** と組み合わせれば隔離された実行になります)。
+- **プランから**:保存したプランを新しいセッションや worktree で実装するとき、ダイアログの **ゴールとして実行** にチェック。ゴールの目標にはプランの内容が入るため、監査はプランそのものに照らして進捗を判定します。
+- **スケジュールで**:[スケジュールタスク](/scheduled-tasks/)の **ゴールとして実行** にチェックすると、定期実行がプロンプトを完了まで追求します。
+
+## 自己完結した目標を書く
+
+進捗の監査モデルが見るのは、あなたの目標とエージェントの最新の返信だけです — チャット履歴は見ません。会話の文脈を知らない人でも完成状態がわかるように、ゴールのメッセージを書いてください。
+
+- 良い例:「エクスポートモジュールのテストを追加し、テストスイート全体を通るようにして。」
+- 良くない例:「直して」「さっきのアイデアで続けて」
+
+ちょっとした文脈依存の指示にはゴールは不要です — 普通のメッセージを送りましょう。
+
+## 仕組み
+
+エージェントが停止してセッションがしばらく静かになると、OpenChamber は:
+
+1. 小型で安価なモデルに、最新のターンを目標と照らして監査させます:続行、完了、それとも行き詰まり?
+2. 判定が「続行」なら、継続プロンプトを送り、エージェントが作業を再開します。
+3. 目標が検証可能な形で達成されていればゴールは完了し、通知が届きます。
+4. エージェントが本当に行き詰まっている(あなたの入力が必要な)場合、ゴールはブロックとして停止します — ただし監査が3回連続でそう判定した場合のみ。一度のつまずきでゴールが終わることはありません。
+
+ハードな安全装置もあります:オプションのトークン予算、自動継続の上限、ターンエラー時の停止です。作業中にセッションのコンテキストが圧縮されても、ゴールはそのまま続行します — コンテキストウィンドウに達したこと自体が、作業が終わっていない証拠だからです。
+
+### 停止と再開
+
+- **停止ボタン**は実行中のターンを中断し、ゴールを一時停止します — あなたの明示的な「止めて」は常にループより優先されます。
+- ストリップの**一時停止**は逆方向から同じことをします:ゴールを一時停止し、実行中のターンを止めます。
+- 一時停止中は普通にチャットできます — ループは邪魔をしません。
+- **再開**はループを再始動します:アイドルなセッションでは継続プロンプトが即座に送られ、エージェントが作業中なら次の停止時にループが静かに再接続します。
+
+## 確認と管理
+
+- コンポーザー上部のストリップに、最新の進捗メモ、ステータス、トークン使用量が表示され、一時停止/再開ボタンも組み込まれています。エージェントが停止していてゴールがアクティブなときは、回転する**評価中…**が表示されます — 静止ウィンドウと監査が動いている印です。
+- ターゲットボタンはゴール実行中は点灯し(青)、完了で緑、ブロックや予算切れで赤になります。押すとゴールのダイアログが開き、目標や予算の編集、ゴールの削除ができます。完了したゴールは読み取り専用です — 削除してから新しいゴールを開始してください。
+- セッションサイドバーでは、セッションの日付の横にゴールの状態色の小さなターゲットが表示されます。
+
+## 通知
+
+ゴールがアクティブな間、ターンごとの「エージェント準備完了」通知は抑制されます — ループ自身の継続をなぞるだけだからです。ゴールが確定すると(完了、ブロック、予算到達)、代わりに最終通知が1件、デスクトップとモバイルプッシュで届きます。「完了時に通知」と同じ設定に従います。権限リクエスト、質問、エラー通知は通常どおり機能し続けます。
+
+## トークン予算
+
+**設定 → チャット → ゴール** で、新しいゴールのデフォルトトークン予算を設定できます。予算に達するとゴールは「予算上限に到達」として停止し、それ以上消費しません — 予算を上げてゴールのダイアログから再開できます。
+
+## 留意点
+
+- ゴールのループはブラウザのタブではなく OpenChamber サーバーで動きます。タブを閉じても、スマホをロックしても — エージェントは働き続け、ゴールが確定すると通知が届きます。サーバー(デスクトップアプリまたは `openchamber` プロセス)は起動したままにしてください。
+- ゴールはセッション自身のプロバイダーとモデルを使います。監査の呼び出しも同様です — 既に使っているプロバイダーの外にデータが出ることはありません。
+- ゴールは1セッションにつき同時に1つです。
+
+## 関連
+
+- [スケジュールタスク](/scheduled-tasks/) — スケジュールでプロンプトを実行。「ゴールとして実行」を有効にすると、スケジュール実行がプロンプトを完了まで追求します
+- [通知](/notifications/) — 完了したゴールを知る方法
diff --git a/packages/docs/content/docs/ko/scheduled-tasks.mdx b/packages/docs/content/docs/ko/scheduled-tasks.mdx
index aef273e9d7..f42e64cfc8 100644
--- a/packages/docs/content/docs/ko/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/ko/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ description: 일정에 따라 프롬프트를 자동으로 실행하세요.
**run now**로 작업을 즉시 실행하여 기대대로 동작하는지 확인할 수 있습니다.
+**목표로 실행**을 체크하면 실행이 한 번의 답변에서 멈추지 않고 프롬프트를 완료까지 추진합니다 — [세션 목표](/session-goals/)를 참고하세요.
+
## 성공이란 어떤 모습인가
실행 후 작업에는 마지막 실행 시각, 성공 여부, 생성된 세션으로의 링크가 표시됩니다. 실행이 실패하면 오류도 거기에 표시됩니다.
diff --git a/packages/docs/content/docs/ko/session-goals.mdx b/packages/docs/content/docs/ko/session-goals.mdx
new file mode 100644
index 0000000000..aa90ff473e
--- /dev/null
+++ b/packages/docs/content/docs/ko/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: 세션 목표
+description: 프롬프트를 목표로 바꾸면 에이전트가 자동으로 계속 작업합니다.
+---
+
+# 세션 목표
+
+목표는 하나의 프롬프트를 결승선으로 바꿉니다. 답변이 올 때마다 "계속해"라고 재촉하는 대신 목표를 한 번만 설정하면 — OpenChamber가 세션을 자동으로 목표를 향해 이끌고, 매 턴이 끝날 때마다 독립적인 감사 모델로 진행 상황을 확인합니다. 자리를 비운 동안에도 계속 작동합니다.
+
+## 목표 시작하기
+
+1. 컴포저의 타깃 버튼을 누릅니다. 불이 켜지면 목표 모드가 준비된 것입니다.
+2. 프롬프트를 작성하고 전송합니다. 그 메시지가 목표가 됩니다.
+
+기존 세션과 새 세션 초안 모두에서 동일하게 작동합니다. 타깃을 켜고 첫 메시지를 작성해 전송하면 — 새 세션이 목표가 이미 활성화된 상태로 시작됩니다.
+
+### 목표를 시작하는 다른 방법
+
+- **에이전트의 답변에서**: "Start new session from this answer" 대화 상자에서 **목표로 실행**을 체크 — 답변이 과제로 넘겨져 새 세션이 완료까지 실행합니다(**Create worktree**와 결합하면 격리된 실행이 됩니다).
+- **계획에서**: 저장된 계획을 새 세션이나 worktree에서 구현할 때 대화 상자의 **목표로 실행**을 체크하세요. 목표에 계획 내용이 담기므로 감사가 실제 계획을 기준으로 진행 상황을 판단합니다.
+- **일정에 따라**: [예약 작업](/scheduled-tasks/)에서 **목표로 실행**을 체크하면 반복 실행이 프롬프트를 완료까지 추진합니다.
+
+## 자기 완결적인 목표 작성하기
+
+진행 감사 모델은 목표와 에이전트의 최신 답변만 봅니다 — 채팅 기록은 보지 않습니다. 따라서 대화 맥락을 모르는 사람도 완료 상태가 어떤 모습인지 이해할 수 있도록 목표 메시지를 작성하세요.
+
+- 좋은 예: "내보내기 모듈에 테스트를 추가하고 전체 테스트 스위트를 통과시켜."
+- 좋지 않은 예: "고쳐줘" 또는 "그 아이디어로 계속해."
+
+작은 맥락적 후속 지시에는 목표가 필요 없습니다 — 그냥 일반 메시지를 보내세요.
+
+## 작동 방식
+
+에이전트가 멈추고 세션이 잠시 조용해지면 OpenChamber는:
+
+1. 작고 저렴한 모델에게 최신 턴을 목표와 대조해 감사하게 합니다: 계속, 완료, 아니면 막힘?
+2. 판정이 "계속"이면 계속 프롬프트를 보내고 에이전트가 작업을 다시 시작합니다.
+3. 목표가 검증 가능하게 달성되면 목표가 완료되고 알림이 옵니다.
+4. 에이전트가 정말로 막혔다면(사용자의 입력이 필요하면) 목표는 차단됨으로 멈춥니다 — 단, 감사가 세 번 연속 그렇게 판정한 후에만요. 한 번의 걸림돌로 목표가 끝나는 일은 없습니다.
+
+강제 안전장치도 있습니다: 선택적 토큰 예산, 자동 계속 횟수 상한, 턴 오류 시 중지. 작업 도중 세션 컨텍스트가 압축되어도 목표는 그냥 계속됩니다 — 컨텍스트 창에 부딪혔다는 것 자체가 작업이 끝나지 않았다는 증거이기 때문입니다.
+
+### 중지와 재개
+
+- **중지 버튼**은 실행 중인 턴을 중단하고 목표를 일시 중지합니다 — 당신의 명시적인 "멈춰"가 항상 루프보다 우선합니다.
+- 스트립의 **일시 중지**는 반대 방향에서 같은 일을 합니다: 목표를 일시 중지하고 실행 중인 턴을 멈춥니다.
+- 일시 중지 중에는 평소처럼 채팅하세요 — 루프가 끼어들지 않습니다.
+- **재개**는 루프를 다시 켭니다: 유휴 세션에서는 계속 프롬프트가 즉시 나가고, 에이전트가 작업 중이라면 다음 멈춤에서 루프가 조용히 다시 붙습니다.
+
+## 확인 및 관리
+
+- 컴포저 위의 스트립에 최신 진행 메모, 상태, 토큰 사용량이 표시되며, 일시 중지/재개 버튼이 함께 있습니다. 에이전트가 멈췄는데 목표가 활성 상태라면 스트립에 회전하는 **평가 중…**이 표시됩니다 — 조용한 대기 시간과 감사가 진행 중이라는 뜻입니다.
+- 타깃 버튼은 목표가 실행 중일 때 켜져 있고(파란색), 완료되면 초록색, 차단되거나 예산이 소진되면 빨간색이 됩니다. 누르면 목표 대화 상자가 열립니다: 목표나 예산을 편집하거나 목표를 제거하세요. 완료된 목표는 읽기 전용입니다 — 제거한 후 새 목표를 시작하세요.
+- 세션 사이드바에서 세션 날짜 옆에 목표 상태 색상의 작은 타깃이 나타납니다.
+
+## 알림
+
+목표가 활성 상태인 동안 턴마다 오는 "에이전트 준비 완료" 알림은 억제됩니다 — 루프 자체의 계속 실행을 되풀이할 뿐이기 때문입니다. 목표가 확정되면(완료, 차단, 예산 도달) 대신 최종 알림 하나가 데스크톱과 모바일 푸시로 옵니다. "완료 시 알림"과 같은 설정을 따릅니다. 권한 요청, 질문, 오류 알림은 평소처럼 계속 작동합니다.
+
+## 토큰 예산
+
+**설정 → 채팅 → 목표**에서 새 목표의 기본 토큰 예산을 설정할 수 있습니다. 목표가 예산에 도달하면 더 소비하지 않고 "예산 도달"로 멈춥니다 — 예산을 올리고 목표 대화 상자에서 재개할 수 있습니다.
+
+## 유의 사항
+
+- 목표 루프는 브라우저 탭이 아니라 OpenChamber 서버에서 실행됩니다. 탭을 닫든 휴대폰을 잠그든 — 에이전트는 계속 일하고, 목표가 확정되면 알림이 옵니다. 서버(데스크톱 앱 또는 `openchamber` 프로세스)는 계속 실행 중이어야 합니다.
+- 목표는 감사 호출을 포함해 세션 자체의 프로바이더와 모델을 사용합니다 — 이미 사용 중인 프로바이더 밖으로 나가는 것은 없습니다.
+- 세션당 목표는 한 번에 하나입니다.
+
+## 관련
+
+- [예약 작업](/scheduled-tasks/) — 일정에 따라 프롬프트 실행; "목표로 실행"을 켜면 예약 실행이 프롬프트를 완료까지 추진합니다
+- [알림](/notifications/) — 완료된 목표를 알게 되는 방법
diff --git a/packages/docs/content/docs/pl/scheduled-tasks.mdx b/packages/docs/content/docs/pl/scheduled-tasks.mdx
index 842ab5e483..8f67621cb5 100644
--- a/packages/docs/content/docs/pl/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/pl/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ Zaplanowane zadanie uruchamia za Ciebie prompt według harmonogramu — na przyk
Dowolne zadanie możesz uruchomić natychmiast za pomocą **run now**, aby sprawdzić, czy robi to, czego oczekujesz.
+Zaznacz **Uruchom jako cel**, aby uruchomienie doprowadziło prompt do końca zamiast zatrzymywać się po jednej odpowiedzi — zobacz [Cele sesji](/session-goals/).
+
## Jak wygląda sukces
Po uruchomieniu zadanie pokazuje, kiedy ostatnio się wykonało, czy się powiodło, oraz link do utworzonej sesji. Jeśli uruchomienie się nie powiedzie, błąd również jest tam pokazany.
diff --git a/packages/docs/content/docs/pl/session-goals.mdx b/packages/docs/content/docs/pl/session-goals.mdx
new file mode 100644
index 0000000000..c1ad1fdd66
--- /dev/null
+++ b/packages/docs/content/docs/pl/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: Cele sesji
+description: Zamień prompt w cel, nad którym agent pracuje automatycznie.
+---
+
+# Cele sesji
+
+Cel zamienia jeden prompt w linię mety. Zamiast popychać agenta słowem „kontynuuj" po każdej odpowiedzi, ustawiasz cel raz — a OpenChamber automatycznie prowadzi sesję w jego stronę, sprawdzając postęp niezależnym audytorem po każdej turze. Praca trwa nawet pod twoją nieobecność.
+
+## Rozpoczęcie celu
+
+1. Naciśnij przycisk celu (tarczę) w kompozytorze. Zapala się — tryb celu jest uzbrojony.
+2. Napisz prompt i wyślij. Ta wiadomość staje się treścią celu.
+
+Działa to tak samo w istniejącej sesji i w szkicu nowej: uzbrój tarczę, napisz pierwszą wiadomość, wyślij — nowa sesja startuje z już aktywnym celem.
+
+### Inne sposoby rozpoczęcia celu
+
+- **Z odpowiedzi agenta**: w oknie „Start new session from this answer" zaznacz **Uruchom jako cel** — odpowiedź zostaje przekazana jako zadanie, które nowa sesja wykonuje do końca (połącz z **Create worktree**, aby uzyskać izolowane uruchomienie).
+- **Z planu**: implementując zapisany plan w nowej sesji lub worktree, zaznacz **Uruchom jako cel** w oknie dialogowym. Treścią celu staje się zawartość planu, więc audytor ocenia postęp względem samego planu.
+- **Według harmonogramu**: zaznacz **Uruchom jako cel** w [zaplanowanym zadaniu](/scheduled-tasks/), aby cykliczne uruchomienia doprowadzały prompt do końca.
+
+## Formułuj cel samowystarczalnie
+
+Audytor postępu widzi tylko treść celu i ostatnią odpowiedź agenta — bez historii czatu. Sformułuj więc wiadomość-cel tak, aby osoba bez kontekstu rozmowy zrozumiała, jak wygląda stan końcowy.
+
+- Dobrze: „Dodaj testy dla modułu eksportu i doprowadź cały zestaw testów do zielonego stanu."
+- Słabiej: „Napraw to" albo „Kontynuuj z tamtym pomysłem."
+
+Do drobnych kontekstowych poleceń cel nie jest potrzebny — wyślij zwykłą wiadomość.
+
+## Jak to działa
+
+Gdy agent się zatrzyma i sesja na chwilę ucichnie, OpenChamber:
+
+1. Prosi mały, tani model o audyt ostatniej tury względem celu: kontynuować, gotowe czy utknięto?
+2. Jeśli werdykt to „kontynuować", wysyła prompt kontynuacji i agent wraca do pracy.
+3. Jeśli cel jest weryfikowalnie osiągnięty, cel się kończy, a ty dostajesz powiadomienie.
+4. Jeśli agent naprawdę utknął (potrzebuje twojego udziału), cel zatrzymuje się jako zablokowany — ale dopiero gdy audytor powie to trzy razy z rzędu, więc jednorazowa przeszkoda nigdy nie kończy celu.
+
+Są też twarde bezpieczniki: opcjonalny budżet tokenów, limit automatycznych kontynuacji i stop przy błędzie tury. Jeśli kontekst sesji zostanie skompaktowany w trakcie pracy, cel po prostu trwa dalej — uderzenie w okno kontekstu to dowód, że praca nie była skończona.
+
+### Zatrzymywanie i wznawianie
+
+- **Przycisk stop** przerywa bieżącą turę i wstrzymuje cel — twoje wyraźne „stop" zawsze wygrywa z pętlą.
+- **Wstrzymaj** na pasku celu robi to samo z drugiej strony: wstrzymuje cel i zatrzymuje bieżącą turę.
+- Podczas wstrzymania rozmawiaj normalnie — pętla nie przeszkadza.
+- **Wznów** ponownie uzbraja pętlę: w bezczynnej sesji zachęta do kontynuacji wychodzi natychmiast; jeśli agent akurat pracuje, pętla po cichu podłącza się przy jego następnej przerwie.
+
+## Podgląd i zarządzanie
+
+- Pasek nad kompozytorem pokazuje ostatnią notatkę postępu, status i zużycie tokenów, wraz z przyciskiem wstrzymaj/wznów. Gdy agent się zatrzymał, a cel jest aktywny, pasek pokazuje wirujące **Ocenianie…** — to okno ciszy i trwający audyt.
+- Przycisk-tarcza świeci, póki cel działa (niebieski), zielenieje po ukończeniu, a czerwienieje przy zablokowaniu lub wyczerpaniu budżetu. Naciśnij go, aby otworzyć okno celu: edytuj treść lub budżet albo usuń cel. Ukończony cel jest tylko do odczytu — usuń go, a potem uzbrój nowy.
+- W panelu bocznym sesji obok daty sesji pojawia się mała tarcza w kolorze stanu celu.
+
+## Powiadomienia
+
+Póki cel jest aktywny, powiadomienia „agent gotowy" po każdej turze są wyciszone — powtarzałyby tylko kontynuacje samej pętli. Gdy cel się rozstrzygnie (ukończony, zablokowany lub budżet wyczerpany), dostajesz zamiast tego jedno końcowe powiadomienie — na desktopie i jako push mobilny. Respektuje to samo ustawienie „powiadamiaj o ukończeniu"; prośby o uprawnienia, pytania i powiadomienia o błędach działają jak zwykle.
+
+## Budżet tokenów
+
+W **Ustawienia → Czat → Cel** możesz ustawić domyślny budżet tokenów dla nowych celów. Po osiągnięciu budżetu cel zatrzymuje się jako „budżet wyczerpany" zamiast wydawać więcej — możesz podnieść budżet i wznowić z okna celu.
+
+## Miej na uwadze
+
+- Pętla celu działa na serwerze OpenChamber, nie w karcie przeglądarki. Zamknij kartę, zablokuj telefon — agent pracuje dalej, a gdy cel się rozstrzygnie, dostaniesz powiadomienie. Serwer (aplikacja desktopowa lub proces `openchamber`) musi pozostać uruchomiony.
+- Cele używają dostawcy i modelu twojej własnej sesji, łącznie z wywołaniami audytora — nic nie trafia do dostawców, których już nie używasz.
+- Jeden cel na sesję naraz.
+
+## Powiązane
+
+- [Zaplanowane zadania](/scheduled-tasks/) — uruchamianie promptu według harmonogramu; włącz tam „Uruchom jako cel", aby zaplanowane uruchomienie doprowadziło prompt do końca
+- [Powiadomienia](/notifications/) — jak dowiadujesz się o ukończonym celu
diff --git a/packages/docs/content/docs/pt-br/scheduled-tasks.mdx b/packages/docs/content/docs/pt-br/scheduled-tasks.mdx
index 2d50f2f61d..4332ee1aee 100644
--- a/packages/docs/content/docs/pt-br/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/pt-br/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ Uma tarefa agendada executa um prompt para você em uma agenda — por exemplo,
Você pode executar qualquer tarefa imediatamente com **run now** para verificar se ela faz o que você espera.
+Marque **Executar como objetivo** para que a execução persiga o prompt até concluir em vez de parar após uma resposta — veja [Objetivos de sessão](/session-goals/).
+
## Como é o sucesso
Após uma execução, a tarefa mostra quando rodou pela última vez, se teve êxito e um link para a sessão que ela criou. Se uma execução falha, o erro também é mostrado ali.
diff --git a/packages/docs/content/docs/pt-br/session-goals.mdx b/packages/docs/content/docs/pt-br/session-goals.mdx
new file mode 100644
index 0000000000..50f48783a5
--- /dev/null
+++ b/packages/docs/content/docs/pt-br/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: Objetivos de sessão
+description: Transforme um prompt em um objetivo no qual o agente trabalha automaticamente.
+---
+
+# Objetivos de sessão
+
+Um objetivo transforma um único prompt em uma linha de chegada. Em vez de cutucar o agente com "continua" após cada resposta, você define o objetivo uma vez — e o OpenChamber mantém a sessão trabalhando em direção a ele automaticamente, verificando o progresso com um auditor independente após cada turno. Continua rodando mesmo enquanto você está ausente.
+
+## Iniciar um objetivo
+
+1. Pressione o botão de alvo no compositor. Ele acende — o modo objetivo está armado.
+2. Escreva seu prompt e envie. Essa mensagem se torna o objetivo.
+
+Funciona igualmente em uma sessão existente e em um rascunho de sessão nova: arme o alvo, escreva a primeira mensagem, envie — a nova sessão começa com o objetivo já ativo.
+
+### Mais formas de iniciar um objetivo
+
+- **A partir de uma resposta do agente**: no diálogo "Start new session from this answer", marque **Executar como objetivo** — a resposta é entregue como uma tarefa que a nova sessão executa até concluir (combine com **Create worktree** para uma execução isolada).
+- **A partir de um plano**: ao implementar um plano salvo em uma sessão ou worktree novos, marque **Executar como objetivo** no diálogo. O objetivo carrega o conteúdo do plano, então o auditor julga o progresso contra o plano real.
+- **Em um cronograma**: marque **Executar como objetivo** em uma [tarefa agendada](/scheduled-tasks/) para que execuções recorrentes persigam o prompt até concluir.
+
+## Escreva um objetivo autocontido
+
+O auditor de progresso vê apenas o seu objetivo e a última resposta do agente — não o histórico do chat. Então formule a mensagem-objetivo de forma que alguém sem o contexto da conversa entenda como é o estado final.
+
+- Bom: "Adicione testes para o módulo de exportação e faça toda a suíte de testes passar."
+- Nem tanto: "Conserta isso" ou "Continua com aquela ideia."
+
+Para pequenos ajustes contextuais você não precisa de um objetivo — envie uma mensagem normal.
+
+## Como funciona
+
+Quando o agente para e a sessão fica quieta por um momento, o OpenChamber:
+
+1. Pede a um modelo pequeno e barato que audite o último turno contra o objetivo: continuar, pronto ou travado?
+2. Se o veredicto for "continuar", envia um prompt de continuação e o agente retoma o trabalho.
+3. Se o objetivo foi alcançado de forma verificável, o objetivo é concluído e você recebe uma notificação.
+4. Se o agente está realmente travado (precisa da sua participação), o objetivo para como bloqueado — mas só depois que o auditor disser isso três vezes seguidas, então um tropeço pontual nunca encerra o objetivo.
+
+Há também freios de segurança: um orçamento de tokens opcional, um teto de continuações automáticas e parada em erros de turno. Se o contexto da sessão for compactado no meio do trabalho, o objetivo simplesmente continua — bater na janela de contexto é prova de que o trabalho não tinha terminado.
+
+### Parar e retomar
+
+- O **botão de parar** aborta o turno em andamento e pausa o objetivo — o seu "para" explícito sempre vence o loop.
+- **Pausar** na faixa do objetivo faz o mesmo pelo outro lado: pausa o objetivo e para o turno em andamento.
+- Enquanto pausado, converse normalmente — o loop fica de fora.
+- **Retomar** rearma o loop: em uma sessão ociosa o empurrão de continuação sai imediatamente; se o agente estiver trabalhando, o loop se reconecta silenciosamente na próxima pausa dele.
+
+## Acompanhar e gerenciar
+
+- A faixa acima do compositor mostra a última nota de progresso, o status e o uso de tokens, com um botão de pausar/retomar integrado. Quando o agente parou e o objetivo está ativo, a faixa mostra um **Avaliando…** girando — é a janela de silêncio e a auditoria em andamento.
+- O botão de alvo fica aceso enquanto o objetivo roda (azul), fica verde ao concluir e vermelho quando bloqueado ou sem orçamento. Pressione-o para abrir o diálogo do objetivo: edite o objetivo ou o orçamento, ou remova-o. Um objetivo concluído é somente leitura — remova-o e arme um novo.
+- Na barra lateral de sessões, um pequeno alvo aparece ao lado da data da sessão, colorido pelo estado do objetivo.
+
+## Notificações
+
+Enquanto um objetivo está ativo, as notificações por turno de "agente pronto" são suprimidas — elas só ecoariam as continuações do próprio loop. Quando o objetivo se resolve (concluído, bloqueado ou orçamento atingido) você recebe uma única notificação final, no desktop e como push móvel. Ela obedece à mesma configuração de "notificar ao concluir"; solicitações de permissão, perguntas e notificações de erro continuam funcionando normalmente.
+
+## Orçamento de tokens
+
+Em **Configurações → Chat → Objetivo** você pode definir um orçamento de tokens padrão para novos objetivos. Quando um objetivo atinge o orçamento, ele para como "orçamento atingido" em vez de gastar mais — você pode aumentar o orçamento e retomar pelo diálogo do objetivo.
+
+## Tenha em mente
+
+- O loop do objetivo roda no servidor do OpenChamber, não na aba do navegador. Feche a aba, bloqueie o celular — o agente continua trabalhando, e você recebe uma notificação quando o objetivo se resolve. O servidor (app desktop ou processo `openchamber`) precisa continuar rodando.
+- Objetivos usam o provedor e o modelo da sua própria sessão, incluindo as chamadas do auditor — nada sai para provedores que você já não use.
+- Um objetivo por sessão de cada vez.
+
+## Relacionado
+
+- [Tarefas agendadas](/scheduled-tasks/) — rodar um prompt em um cronograma; ative lá "Executar como objetivo" para que a execução agendada persiga o prompt até concluir
+- [Notificações](/notifications/) — como você fica sabendo de um objetivo concluído
diff --git a/packages/docs/content/docs/scheduled-tasks.mdx b/packages/docs/content/docs/scheduled-tasks.mdx
index fe4bb48ad7..e775319050 100644
--- a/packages/docs/content/docs/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s
You can run any task immediately with **run now** to check it does what you expect.
+Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/).
+
## What success looks like
After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too.
diff --git a/packages/docs/content/docs/session-goals.mdx b/packages/docs/content/docs/session-goals.mdx
new file mode 100644
index 0000000000..0a672373d9
--- /dev/null
+++ b/packages/docs/content/docs/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: Session Goals
+description: Turn a prompt into a goal the agent keeps working toward automatically.
+---
+
+# Session Goals
+
+A goal turns one prompt into a finish line. Instead of nudging the agent with "continue" after every reply, you set a goal once — and OpenChamber keeps the session working toward it automatically, checking progress with an independent auditor after every turn. It keeps running even while you are away.
+
+## Start a goal
+
+1. Press the target button in the composer. It lights up — goal mode is armed.
+2. Type your prompt and send it. That message becomes the goal's objective.
+
+This works in an existing session and in a new session draft alike: arm the target, write the first message, send — the new session starts with the goal already active.
+
+### More ways to start a goal
+
+- **From an agent's reply**: in the "Start new session from this answer" dialog, check **Run as goal** — the reply is handed over as an assignment the new session executes to completion (combine with **Create worktree** for an isolated run).
+- **From a plan**: when implementing a saved plan in a new session or worktree, check **Run as goal** in the dialog. The goal carries the plan content as its objective, so the auditor judges progress against the actual plan.
+- **On a schedule**: check **Run as goal** on a [scheduled task](/scheduled-tasks/) to make recurring runs pursue their prompt to completion.
+
+## Write a self-contained objective
+
+The progress auditor sees only your objective and the agent's latest reply — not the chat history. So phrase the goal message so that someone without the conversation context would understand what the finished state looks like.
+
+- Good: "Add tests for the export module and make the whole test suite pass."
+- Not so good: "Fix it" or "Continue with that idea."
+
+For small contextual follow-ups you don't need a goal — just send a normal message.
+
+## How it works
+
+After the agent stops and the session stays quiet for a moment, OpenChamber:
+
+1. Asks a small, cheap model to audit the latest turn against the objective: keep going, done, or stuck?
+2. If the verdict is "keep going", it sends a continuation prompt and the agent picks the work back up.
+3. If the objective is verifiably achieved, the goal completes and you get a notification.
+4. If the agent is genuinely stuck (needs your input), the goal stops as blocked — but only after the auditor says so three times in a row, so a one-off snag never ends the goal.
+
+There are hard safety stops too: an optional token budget, a cap on automatic continuations, and a stop on turn errors. If the session's context gets compacted mid-work, the goal simply continues — running into the context window is proof the work wasn't finished.
+
+### Stopping and resuming
+
+- The **stop button** aborts the running turn and pauses the goal — your explicit "stop" always wins over the loop.
+- **Pause** on the goal strip does the same from the other direction: it pauses the goal and stops the running turn.
+- While paused, chat normally — the loop stays out of the way.
+- **Resume** re-arms the loop: on an idle session the continuation nudge goes out immediately; if the agent happens to be working, the loop silently re-attaches at its next pause.
+
+## Watch and manage
+
+- The strip above the composer shows the goal's latest progress note, status, and token usage, with an inline pause/resume button. When the agent has stopped and the goal is active, the strip shows a spinning **Evaluating…** — that's the quiet window and the audit running.
+- The target button stays lit while the goal runs (blue), turns green on completion, and red when blocked or out of budget. Press it to open the goal dialog: edit the objective or budget, or remove the goal. A completed goal is read-only — remove it, then arm a new one.
+- In the session sidebar, a small target appears next to the session date, colored by the goal's state.
+
+## Notifications
+
+While a goal is active, the per-turn "agent is ready" notifications are suppressed — they would just echo the goal loop's own continuations. When the goal settles (complete, blocked, or budget reached) you get one final notification instead, on desktop and as a mobile push. It obeys the same "notify on completion" setting; permission requests, questions, and error notifications keep working as usual throughout.
+
+## Token budget
+
+In **Settings → Chat → Goal** you can set a default token budget for new goals. When a goal reaches its budget it stops as "budget reached" instead of spending more — you can raise the budget and resume from the goal dialog.
+
+## Keep in mind
+
+- The goal loop runs in the OpenChamber server, not in your browser tab. Close the tab, lock the phone — the agent keeps working, and you get a notification when the goal settles. The server (desktop app or `openchamber` process) must stay running.
+- Goals use your session's own provider and model, including the auditor calls — nothing leaves the providers you already use.
+- One goal per session at a time.
+
+## Related
+
+- [Scheduled Tasks](/scheduled-tasks/) — run a prompt on a schedule; enable "Run as goal" there to make a scheduled run pursue its prompt to completion
+- [Notifications](/notifications/) — how you hear about a finished goal
diff --git a/packages/docs/content/docs/uk/scheduled-tasks.mdx b/packages/docs/content/docs/uk/scheduled-tasks.mdx
index a2e3346874..de2b055ad6 100644
--- a/packages/docs/content/docs/uk/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/uk/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ description: Запускайте промпт автоматично за ро
Ви можете запустити будь-яке завдання негайно через **run now**, щоб перевірити, що воно робить те, що ви очікуєте.
+Позначте **Run as goal**, щоб запуск доводив промпт до завершення, а не зупинявся після однієї відповіді — див. [Цілі сесії](/session-goals/).
+
## Як виглядає успіх
Після запуску завдання показує, коли воно востаннє виконувалося, чи воно успішне, і посилання на сесію, яку воно створило. Якщо запуск збоїть, помилка теж показується там.
diff --git a/packages/docs/content/docs/uk/session-goals.mdx b/packages/docs/content/docs/uk/session-goals.mdx
new file mode 100644
index 0000000000..d1a49d238a
--- /dev/null
+++ b/packages/docs/content/docs/uk/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: Цілі сесії
+description: Перетворіть промпт на ціль, до якої агент рухається автоматично.
+---
+
+# Цілі сесії
+
+Ціль перетворює один промпт на фінішну пряму. Замість підштовхувати агента словом «продовжуй» після кожної відповіді, ви ставите ціль один раз — і OpenChamber автоматично веде сесію до неї, перевіряючи прогрес незалежним аудитором після кожного ходу. Робота триває, навіть поки вас немає поруч.
+
+## Як розпочати ціль
+
+1. Натисніть кнопку-мішень у полі вводу. Вона засвітиться — режим цілі увімкнено.
+2. Напишіть промпт і надішліть. Це повідомлення стане формулюванням цілі.
+
+Це працює однаково і в наявній сесії, і в чернетці нової: увімкніть мішень, напишіть перше повідомлення, надішліть — нова сесія почнеться вже з активною ціллю.
+
+### Інші способи розпочати ціль
+
+- **З відповіді агента**: у діалозі «Start new session from this answer» позначте **Run as goal** — відповідь передається як завдання, яке нова сесія виконує до завершення (комбінуйте з **Create worktree** для ізольованого запуску).
+- **З плану**: виконуючи збережений план у новій сесії чи worktree, позначте **Run as goal** у діалозі. Формулюванням цілі стане зміст плану, тож аудитор судитиме прогрес за самим планом.
+- **За розкладом**: позначте **Run as goal** у [запланованому завданні](/scheduled-tasks/), щоб регулярні запуски доводили промпт до завершення.
+
+## Формулюйте ціль самодостатньо
+
+Аудитор прогресу бачить лише ваше формулювання цілі та останню відповідь агента — без історії чату. Тому пишіть повідомлення-ціль так, щоб людина без контексту розмови зрозуміла, як виглядає завершений стан.
+
+- Добре: «Додай тести для модуля експорту й доведи весь тестовий набір до зеленого стану.»
+- Не дуже: «Виправ це» або «Продовжуй з тією ідеєю.»
+
+Для дрібних контекстних уточнень ціль не потрібна — просто надішліть звичайне повідомлення.
+
+## Як це працює
+
+Коли агент зупиняється і сесія трохи затихає, OpenChamber:
+
+1. Просить малу, дешеву модель оцінити останній хід відносно цілі: продовжувати, готово чи глухий кут?
+2. Якщо вердикт «продовжувати» — надсилає промпт продовження, і агент береться за роботу знову.
+3. Якщо ціль перевірено досягнута — вона завершується, а ви отримуєте сповіщення.
+4. Якщо агент справді застряг (потрібна ваша участь), ціль зупиняється як заблокована — але лише після того, як аудитор скаже це тричі поспіль, тож разова заминка ніколи не завершує ціль.
+
+Є й жорсткі запобіжники: опційний бюджет токенів, ліміт автоматичних продовжень і зупинка при помилці ходу. Якщо контекст сесії стиснеться посеред роботи, ціль просто продовжиться — впертися у вікно контексту означає, що робота не завершена.
+
+### Зупинка і відновлення
+
+- **Кнопка стоп** обриває поточний хід і призупиняє ціль — ваше явне «стоп» завжди сильніше за цикл.
+- **Pause** на смужці цілі робить те саме з іншого боку: призупиняє ціль і зупиняє поточний хід.
+- Поки ціль на паузі, спілкуйтеся як завжди — цикл не втручається.
+- **Resume** знову вмикає цикл: на сесії у простої промпт продовження летить негайно; якщо агент саме працює — цикл тихо підхопиться на його наступній паузі.
+
+## Спостереження і керування
+
+- Смужка над полем вводу показує останню нотатку прогресу, статус і використання токенів, а також кнопку призупинення/відновлення. Коли агент зупинився, а ціль активна, смужка показує обертовий **Оцінювання…** — це вікно тиші та робота аудитора.
+- Кнопка-мішень світиться, поки ціль працює (синім), стає зеленою при завершенні та червоною, коли ціль заблокована чи вичерпала бюджет. Натисніть її, щоб відкрити діалог цілі: відредагувати формулювання чи бюджет або видалити ціль. Завершена ціль лише для читання — видаліть її, а тоді вмикайте нову.
+- У бічній панелі сесій біля дати сесії з'являється маленька мішень, забарвлена за станом цілі.
+
+## Сповіщення
+
+Поки ціль активна, сповіщення «агент готовий» після кожного ходу придушені — вони лише повторювали б продовження самого циклу. Коли ціль завершується (готово, заблоковано чи вичерпано бюджет), натомість приходить одне фінальне сповіщення — на десктопі та мобільним пушем. Воно поважає те саме налаштування «сповіщати про завершення»; запити дозволів, питання та сповіщення про помилки працюють як завжди.
+
+## Бюджет токенів
+
+У **Налаштування → Чат → Ціль** можна задати типовий бюджет токенів для нових цілей. Досягнувши бюджету, ціль зупиняється як «бюджет вичерпано» замість витрачати більше — можна підняти бюджет і відновити її з діалогу цілі.
+
+## Варто знати
+
+- Цикл цілі працює в сервері OpenChamber, а не у вкладці браузера. Закрийте вкладку, заблокуйте телефон — агент працює далі, а коли ціль завершиться, прийде сповіщення. Сервер (десктопна апка або процес `openchamber`) має залишатися запущеним.
+- Цілі використовують провайдера й модель самої сесії, включно з викликами аудитора — нічого не йде до провайдерів, якими ви не користуєтесь.
+- Одна ціль на сесію за раз.
+
+## Дивіться також
+
+- [Заплановані завдання](/scheduled-tasks/) — запуск промпта за розкладом; увімкніть там «Виконати як ціль», щоб запланований запуск довів промпт до завершення
+- [Сповіщення](/notifications/) — як ви дізнаєтеся про завершену ціль
diff --git a/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx b/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx
index 1d642501d1..f95a41c1b9 100644
--- a/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx
+++ b/packages/docs/content/docs/zh-cn/scheduled-tasks.mdx
@@ -20,6 +20,8 @@ description: 按计划自动运行提示词。
你可以用 **run now** 立即运行任何任务,以检查它是否按预期工作。
+勾选**作为目标运行**,运行就会把提示词推进到完成,而不是在一次回复后停下 — 参见[会话目标](/session-goals/)。
+
## 成功的样子
运行之后,任务会显示它上次运行的时间、是否成功,以及指向它所创建会话的链接。如果某次运行失败,错误也会显示在那里。
diff --git a/packages/docs/content/docs/zh-cn/session-goals.mdx b/packages/docs/content/docs/zh-cn/session-goals.mdx
new file mode 100644
index 0000000000..6e3a4db1c5
--- /dev/null
+++ b/packages/docs/content/docs/zh-cn/session-goals.mdx
@@ -0,0 +1,73 @@
+---
+title: 会话目标
+description: 将一条提示词变成目标,代理会自动持续朝它推进。
+---
+
+# 会话目标
+
+目标把一条提示词变成终点线。你不用在每次回复后催促代理"继续",只需设置一次目标 — OpenChamber 会自动让会话朝目标推进,并在每一轮之后用独立的审核模型检查进度。即使你不在电脑前,它也会继续运行。
+
+## 启动目标
+
+1. 按下输入框中的靶心按钮。它亮起 — 目标模式已就绪。
+2. 输入提示词并发送。这条消息就成为目标内容。
+
+在现有会话和新会话草稿中都一样:启用靶心,写下第一条消息,发送 — 新会话一开始就带着已激活的目标。
+
+### 启动目标的更多方式
+
+- **从代理的回复**:在 "Start new session from this answer" 对话框中勾选**作为目标运行** — 回复将作为任务移交,新会话会把它执行到完成(与 **Create worktree** 结合可获得隔离的运行环境)。
+- **从计划**:在新会话或 worktree 中实施已保存的计划时,在对话框中勾选**作为目标运行**。目标会携带计划内容,因此审核会以实际计划为准判断进度。
+- **按计划**:在[计划任务](/scheduled-tasks/)上勾选**作为目标运行**,让周期性运行把提示词推进到完成。
+
+## 写一个自包含的目标
+
+进度审核模型只能看到你的目标和代理的最新回复 — 看不到聊天历史。因此,请把目标消息写得让一个不了解对话上下文的人也能明白完成状态是什么样子。
+
+- 好的写法:"为导出模块添加测试,并让整个测试套件通过。"
+- 不太好的写法:"修一下" 或 "按那个思路继续。"
+
+对于小的上下文跟进,不需要目标 — 发一条普通消息就行。
+
+## 工作原理
+
+当代理停下且会话安静片刻后,OpenChamber 会:
+
+1. 让一个小而便宜的模型将最新一轮与目标对照审核:继续、完成,还是卡住了?
+2. 如果判定是"继续",就发送续跑提示词,代理重新开始工作。
+3. 如果目标已被可验证地达成,目标即完成,你会收到通知。
+4. 如果代理真的卡住了(需要你的介入),目标会以"已阻塞"停止 — 但只有在审核连续三次这样判定之后,所以一次小挫折绝不会终结目标。
+
+还有硬性安全限制:可选的令牌预算、自动续跑次数上限,以及轮次出错时停止。如果会话上下文在工作途中被压缩,目标会照常继续 — 撞上上下文窗口本身就证明工作还没完成。
+
+### 停止与恢复
+
+- **停止按钮**会中断正在运行的轮次并暂停目标 — 你明确的"停"永远优先于循环。
+- 条带上的**暂停**从另一个方向做同样的事:暂停目标并停止正在运行的轮次。
+- 暂停期间正常聊天即可 — 循环不会打扰。
+- **继续**重新启动循环:在空闲会话上,续跑提示词会立即发出;如果代理恰好在工作,循环会在它下一次停顿时静静接上。
+
+## 查看与管理
+
+- 输入框上方的条带显示目标的最新进度备注、状态和令牌用量,并内置暂停/继续按钮。当代理已停止而目标仍激活时,条带会显示旋转的**评估中…** — 那是静默窗口和审核正在运行。
+- 靶心按钮在目标运行时保持点亮(蓝色),完成时变绿,阻塞或预算耗尽时变红。按下它可打开目标对话框:编辑目标或预算,或移除目标。已完成的目标为只读 — 先移除,再启动新目标。
+- 在会话侧边栏中,会话日期旁会出现一个小靶心,颜色对应目标状态。
+
+## 通知
+
+目标激活期间,每轮的"代理就绪"通知会被抑制 — 它们只会重复循环自己的续跑。目标尘埃落定时(完成、阻塞或达到预算),你会收到一条最终通知,出现在桌面并作为移动推送。它遵循同一个"完成时通知"设置;权限请求、提问和错误通知全程照常工作。
+
+## 令牌预算
+
+在 **设置 → 聊天 → 目标** 中可以为新目标设置默认令牌预算。目标达到预算时会以"已达预算"停止而不再消耗 — 你可以提高预算并从目标对话框中恢复。
+
+## 注意事项
+
+- 目标循环运行在 OpenChamber 服务器中,而不是浏览器标签页里。关掉标签页、锁上手机 — 代理继续工作,目标尘埃落定时你会收到通知。服务器(桌面应用或 `openchamber` 进程)必须保持运行。
+- 目标使用会话自身的提供商和模型,包括审核调用 — 数据不会流向你未在使用的提供商。
+- 每个会话同时只能有一个目标。
+
+## 相关
+
+- [计划任务](/scheduled-tasks/) — 按计划运行提示词;在那里启用"作为目标运行",让计划运行将提示词推进到完成
+- [通知](/notifications/) — 如何得知目标已完成
diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json
index 2195548d08..9529d2c0fe 100644
--- a/packages/docs/sidebar.config.json
+++ b/packages/docs/sidebar.config.json
@@ -154,6 +154,20 @@
"ja": "スケジュールタスク"
}
},
+ {
+ "label": "Session Goals",
+ "link": "/session-goals/",
+ "translations": {
+ "uk": "Цілі сесії",
+ "zh-CN": "会话目标",
+ "es": "Objetivos de sesión",
+ "pt-BR": "Objetivos de sessão",
+ "ko": "세션 목표",
+ "pl": "Cele sesji",
+ "fr": "Objectifs de session",
+ "ja": "セッションゴール"
+ }
+ },
{
"label": "Project Actions",
"link": "/project-actions/",
diff --git a/packages/electron/README.md b/packages/electron/README.md
index 5939a916e7..101d19b4ff 100644
--- a/packages/electron/README.md
+++ b/packages/electron/README.md
@@ -1,6 +1,6 @@
# OpenChamber Desktop
-Electron desktop runtime for OpenChamber on macOS and Windows.
+Electron desktop runtime for OpenChamber on macOS, Windows, and Linux.
This package owns the native shell: windows, menus, deep links, native notifications, auto-updates, host switching, SSH connections, tunnel helpers, and packaged desktop builds. The web UI and OpenChamber server logic still live in `packages/web` and shared React UI lives in `packages/ui`.
@@ -66,7 +66,7 @@ That runs, in order:
Build output goes to `packages/electron/dist`.
-macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS installer.
+macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS installer. Linux builds produce an AppImage for the native x64 or arm64 host.
## Platform Notes
@@ -74,7 +74,19 @@ macOS packaging needs Xcode/build tools for notarized builds and icon asset comp
Windows packaging needs NSIS support through `electron-builder`. If no Windows signing env is set, `package.mjs` disables code signing and builds an unsigned installer.
-The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows.
+Linux AppImages must be built natively. Set `OPENCHAMBER_TARGET_ARCH=x64` or `OPENCHAMBER_TARGET_ARCH=arm64` when packaging; the build rejects a target that does not match the Linux host. The same target selects the bundled OpenCode CLI, native Electron rebuild, and Electron Builder architecture. Linux identity is stable across architectures: executable `openchamber`, desktop file `openchamber.desktop`, icon `openchamber`, and `StartupWMClass=openchamber`.
+
+After packaging, run `bun run --cwd packages/electron verify:linux-appimage`. The verifier extracts the final AppImage and checks its ELF architecture, desktop identity, Electron executable, pinned OpenCode CLI version and architecture, and all packaged native `.node` modules.
+
+Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libfuse2` / `libfuse2t64` on Debian/Ubuntu). Without FUSE, start with `APPIMAGE_EXTRACT_AND_RUN=1`. Keep the AppImage on a writable path so in-app updates can replace it.
+
+Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet).
+
+### Updater End-to-End Fixture
+
+A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture.
+
+The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls and auto-update; system tray and launch-at-login remain macOS/Windows only. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS and Windows (Linux returns an empty list without errors).
## Bundled OpenCode CLI
@@ -98,6 +110,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
| `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server |
| `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version |
+| `OPENCHAMBER_TARGET_ARCH` | Explicit desktop package architecture (`x64` or `arm64`); Linux requires it to match the native host |
| `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server |
| `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead |
| `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally |
diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs
index 70fa534b6e..aec816cc10 100644
--- a/packages/electron/main.mjs
+++ b/packages/electron/main.mjs
@@ -14,6 +14,9 @@ import { ElectronSshManager } from './ssh-manager.mjs';
import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs';
+import { assertUpdaterCapability } from './updater-capability.mjs';
+import { checkForDesktopUpdate } from './updater-check.mjs';
+import { resolveUpdaterFeed } from './updater-feed.mjs';
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
const execFileAsync = promisify(execFile);
@@ -60,6 +63,9 @@ const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) =>
// Set the product name early so electron-log derives its log directory as
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
app.setName('OpenChamber');
+if (process.platform === 'linux') {
+ app.setDesktopName('openchamber.desktop');
+}
if (isDev) {
app.setPath('userData', path.join(app.getPath('appData'), 'OpenChamber Dev'));
}
@@ -862,13 +868,37 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => {
}
};
-const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}) => {
+const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}, expectedServerId = '') => {
const versionUrl = buildVersionUrl(url);
if (!versionUrl) {
throw new Error('Invalid URL');
}
const started = Date.now();
+
+ // Identity gate for learned/untrusted addresses: verify the UNAUTHENTICATED
+ // /health identity before the token-carrying version fetch, so the bearer
+ // token is never sent to a re-assigned address that now belongs to a
+ // different machine. Older servers omit serverId from /health; only an
+ // explicit mismatch rejects.
+ if (typeof expectedServerId === 'string' && expectedServerId.trim()) {
+ const healthUrl = buildHealthUrl(url);
+ if (healthUrl) {
+ try {
+ const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'application/json' } });
+ if (response.ok) {
+ const payload = await response.json().catch(() => null);
+ const reported = typeof payload?.serverId === 'string' ? payload.serverId.trim() : '';
+ if (reported && reported !== expectedServerId.trim()) {
+ return { status: 'wrong-service', latencyMs: Date.now() - started };
+ }
+ }
+ } catch {
+ // Unreachable/timeout surfaces in the version fetch below.
+ }
+ }
+ }
+
try {
const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' };
const token = typeof clientToken === 'string' ? clientToken.trim() : '';
@@ -2158,12 +2188,11 @@ const readThemeSource = () => {
};
const getWindowIconPath = () => {
- if (process.platform !== 'win32' && process.platform !== 'linux') {
- return undefined;
- }
+ if (process.platform !== 'win32' && process.platform !== 'linux') return undefined;
+ const iconFileName = process.platform === 'linux' ? 'icon.png' : 'icon.ico';
const iconPath = isDev
- ? path.join(__dirname, 'resources', 'icons', 'icon.ico')
- : path.join(process.resourcesPath, 'icons', 'icon.ico');
+ ? path.join(__dirname, 'resources', 'icons', iconFileName)
+ : path.join(process.resourcesPath, 'icons', iconFileName);
return fs.existsSync(iconPath) ? iconPath : undefined;
};
@@ -2185,7 +2214,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {};
const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion());
- const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32';
+ const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux';
+ const usesCustomTitleBar = process.platform === 'darwin' || usesFramelessChrome;
// macOS vibrancy, on by default; users can disable it (Appearance settings).
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
const titleBarOverlayEnabled = false;
@@ -2207,7 +2237,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
// here: setting it in the constructor leaves the material uncomposited on a
// cold launch until a window event. No `transparent: true` either — vibrancy
// alone is enough and composites reliably once applied to a live window.
- frame: process.platform === 'win32' ? false : undefined,
+ frame: usesFramelessChrome ? false : undefined,
autoHideMenuBar: autoHidesNativeMenuBar,
// Electron's hiddenInset adds its own extra inset, which leaves the controls
// visibly lower than the app header. Use a plain hidden title bar instead.
@@ -2580,6 +2610,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {};
const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion());
+ const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux';
// macOS vibrancy, on by default; users can disable it (Appearance settings).
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
const browserWindow = new BrowserWindow({
@@ -2595,9 +2626,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
// here: setting it in the constructor leaves the material uncomposited on a
// cold launch until a window event. No `transparent: true` either — vibrancy
// alone is enough and composites reliably once applied to a live window.
- frame: process.platform === 'win32' ? false : undefined,
+ frame: usesFramelessChrome ? false : undefined,
autoHideMenuBar: process.platform !== 'darwin',
- titleBarStyle: process.platform === 'darwin' || process.platform === 'win32' ? 'hidden' : 'default',
+ titleBarStyle: process.platform === 'darwin' || usesFramelessChrome ? 'hidden' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
webPreferences: {
additionalArguments: [
@@ -2795,10 +2826,6 @@ const compareSemver = (left, right) => {
return 0;
};
-const parseGithubRepo = () => {
- return { owner: 'openchamber', repo: 'openchamber' };
-};
-
const setupAutoUpdater = () => {
if (!app.isPackaged) {
return;
@@ -2810,11 +2837,13 @@ const setupAutoUpdater = () => {
autoUpdater.disableWebInstaller = false;
autoUpdater.logger = log;
- const { owner, repo } = parseGithubRepo();
- autoUpdater.setFeedURL({
- provider: 'github',
- owner,
- repo,
+ const testBuild = typeof __OPENCHAMBER_UPDATER_E2E_BUILD__ !== 'undefined'
+ && __OPENCHAMBER_UPDATER_E2E_BUILD__ === true;
+ const feed = resolveUpdaterFeed({ testBuild });
+ autoUpdater.setFeedURL(feed);
+ log.info('[electron] updater feed configured', {
+ provider: feed.provider,
+ target: feed.provider === 'github' ? `${feed.owner}/${feed.repo}` : feed.url,
});
autoUpdater.on('download-progress', (progress) => {
@@ -3774,7 +3803,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
emitToAllWindows('openchamber:installed-apps-updated', apps);
};
if (process.platform !== 'darwin' && process.platform !== 'win32') {
- throw new Error('desktop_get_installed_apps is only supported on macOS and Windows');
+ return { apps: [], hasCache: false, isCacheStale: false, supported: false };
}
if (!hasCache || isCacheStale || args.force === true) {
void refresh();
@@ -3814,7 +3843,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return getOrCreateDesktopInstallId();
case 'desktop_host_probe':
- return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {});
+ return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}, String(args.expectedServerId || ''));
case 'desktop_remote_password_login':
return loginRemoteAndIssueClientToken({
@@ -3877,22 +3906,18 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_check_for_updates': {
+ assertUpdaterCapability({ packaged: app.isPackaged });
const currentVersion = APP_VERSION;
- let updateResult = null;
- try {
- updateResult = await autoUpdater.checkForUpdates();
- } catch {
- }
-
- const updateInfo = updateResult?.updateInfo;
- const nextVersion =
- (typeof updateInfo?.version === 'string' && updateInfo.version) ||
- currentVersion;
- const available = compareSemver(nextVersion, currentVersion) > 0;
+ const { available, updateInfo, updateResult, nextVersion, pendingUpdate } = await checkForDesktopUpdate({
+ autoUpdater,
+ currentVersion,
+ pendingUpdate: state.pendingUpdate,
+ compareVersions: compareSemver,
+ });
const body =
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
await parseRelevantChangelogNotes(currentVersion, nextVersion);
- state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null;
+ state.pendingUpdate = pendingUpdate;
return {
available,
currentVersion,
@@ -3905,6 +3930,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_download_and_install_update':
+ assertUpdaterCapability({ packaged: app.isPackaged });
if (!state.pendingUpdate) {
throw new Error('No pending update');
}
@@ -3950,6 +3976,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_restart': {
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
+ if (applyUpdate) assertUpdaterCapability({ packaged: app.isPackaged });
log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`);
if (applyUpdate && process.platform === 'darwin' && typeof app.isInApplicationsFolder === 'function') {
try {
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 63ac26b414..74911df9e6 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "@openchamber/electron",
- "version": "1.15.0",
+ "version": "1.16.0",
"private": true,
"description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber",
@@ -21,7 +21,8 @@
"Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation",
"macOS: Xcode + build tools for notarized packaging",
- "Windows: NSIS installed for installer creation"
+ "Windows: NSIS installed for installer creation",
+ "Linux: native x64 or arm64 host (no cross-arch packaging); FUSE/libfuse2 to run AppImages, or APPIMAGE_EXTRACT_AND_RUN=1"
],
"scripts": {
"dev": "node ./scripts/electron-dev.mjs",
@@ -30,9 +31,14 @@
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged",
+ "verify:linux-appimage": "node ./scripts/verify-linux-appimage.mjs",
"bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
+ "test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs",
+ "test:updater": "node --test ./updater-capability.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
+ "updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs",
+ "verify:update-manifest": "node ./scripts/verify-update-manifest.mjs",
"package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs",
@@ -54,6 +60,10 @@
"from": "resources/icons/icon.ico",
"to": "icons/icon.ico"
},
+ {
+ "from": "resources/icons/icon.png",
+ "to": "icons/icon.png"
+ },
{
"from": "resources/icons/tray",
"to": "icons/tray"
@@ -95,6 +105,23 @@
"verifyUpdateCodeSignature": false,
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
},
+ "linux": {
+ "target": [
+ "AppImage"
+ ],
+ "category": "Development",
+ "icon": "resources/icons/icon.png",
+ "executableName": "openchamber",
+ "artifactName": "${productName}-${version}-linux-${arch}.${ext}",
+ "desktop": {
+ "entry": {
+ "Name": "OpenChamber",
+ "Comment": "Desktop runtime for OpenChamber",
+ "Icon": "openchamber",
+ "StartupWMClass": "openchamber"
+ }
+ }
+ },
"nsis": {
"oneClick": true,
"perMachine": false,
diff --git a/packages/electron/scripts/bundle-main.mjs b/packages/electron/scripts/bundle-main.mjs
index e8d06d368d..1c867134ac 100644
--- a/packages/electron/scripts/bundle-main.mjs
+++ b/packages/electron/scripts/bundle-main.mjs
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
+const updaterE2eBuild = process.env.OPENCHAMBER_UPDATER_E2E_BUILD === '1';
const result = await Bun.build({
entrypoints: [path.join(root, 'main.mjs')],
@@ -34,6 +35,9 @@ const result = await Bun.build({
minify: false,
sourcemap: 'none',
naming: '[name].mjs',
+ define: {
+ __OPENCHAMBER_UPDATER_E2E_BUILD__: updaterE2eBuild ? 'true' : 'false',
+ },
});
if (!result.success) {
@@ -41,4 +45,4 @@ if (!result.success) {
process.exit(1);
}
-console.log('[electron] main.mjs bundled -> dist-bundle/main.mjs');
+console.log(`[electron] main.mjs bundled -> dist-bundle/main.mjs (updater E2E=${updaterE2eBuild})`);
diff --git a/packages/electron/scripts/finalize-latest-yml.mjs b/packages/electron/scripts/finalize-latest-yml.mjs
index 68b2de1895..2b657a92bf 100644
--- a/packages/electron/scripts/finalize-latest-yml.mjs
+++ b/packages/electron/scripts/finalize-latest-yml.mjs
@@ -85,12 +85,6 @@ if (winX64 || winArm64) {
});
}
-const linuxX64 = await read('latest-yml-x86_64-unknown-linux-gnu', 'latest-linux.yml');
-if (linuxX64) output['latest-linux.yml'] = serialize(linuxX64);
-
-const linuxArm64 = await read('latest-yml-aarch64-unknown-linux-gnu', 'latest-linux-arm64.yml');
-if (linuxArm64) output['latest-linux-arm64.yml'] = serialize(linuxArm64);
-
const macX64 = await read('latest-yml-x86_64-apple-darwin', 'latest-mac.yml');
const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml');
if (macX64 || macArm64) {
diff --git a/packages/electron/scripts/package.mjs b/packages/electron/scripts/package.mjs
index bfceffa96b..c59993e7e4 100644
--- a/packages/electron/scripts/package.mjs
+++ b/packages/electron/scripts/package.mjs
@@ -1,8 +1,11 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
+import { resolveTargetArchitecture } from './target-architecture.mjs';
const env = { ...process.env };
+const builderArgs = process.argv.slice(2);
+const targetArchitecture = resolveTargetArchitecture({ environment: env, builderArgs });
if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) {
env.CSC_IDENTITY_AUTO_DISCOVERY = 'false';
@@ -22,7 +25,13 @@ const bunBinary = bunBinaryCandidates.find((candidate) => {
return false;
}) || (process.platform === 'win32' ? 'bun.exe' : 'bun');
-const child = spawn(bunBinary, ['x', 'electron-builder', ...process.argv.slice(2)], {
+if (process.platform === 'linux' && !builderArgs.some((argument) => (
+ argument === '--x64' || argument === '--arm64' || argument === '--arch' || argument.startsWith('--arch=')
+))) {
+ builderArgs.push(`--${targetArchitecture.electronBuilder}`);
+}
+
+const child = spawn(bunBinary, ['x', 'electron-builder', ...builderArgs], {
env,
stdio: 'inherit',
});
diff --git a/packages/electron/scripts/prepare-opencode-cli.mjs b/packages/electron/scripts/prepare-opencode-cli.mjs
index d7f5ede5c8..06834f0dda 100644
--- a/packages/electron/scripts/prepare-opencode-cli.mjs
+++ b/packages/electron/scripts/prepare-opencode-cli.mjs
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import { resolveTargetArchitecture } from './target-architecture.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..');
@@ -39,8 +40,8 @@ const readPinnedSdkVersion = () => {
return trimmed;
};
-const artifactForCurrentPlatform = () => {
- const { platform, arch } = process;
+const artifactForPlatform = (platform, targetArchitecture) => {
+ const arch = targetArchitecture.opencode;
if (platform === 'darwin') {
if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' };
if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' };
@@ -134,7 +135,8 @@ const main = async () => {
throw new Error(`Invalid OpenCode CLI version: ${version}`);
}
- const artifact = artifactForCurrentPlatform();
+ const targetArchitecture = resolveTargetArchitecture();
+ const artifact = artifactForPlatform(process.platform, targetArchitecture);
const outputBinary = outputBinaryPath(artifact.binary);
const existingVersion = readBinaryVersion(outputBinary);
if (existingVersion === version) {
@@ -142,7 +144,7 @@ const main = async () => {
return;
}
- const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`);
+ const cacheDir = path.join(cacheRoot, version, `${process.platform}-${targetArchitecture.opencode}`);
const archivePath = path.join(cacheDir, artifact.name);
const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`;
if (!fs.existsSync(archivePath)) {
diff --git a/packages/electron/scripts/rebuild-native.mjs b/packages/electron/scripts/rebuild-native.mjs
index cd2a08bd34..ed4d52b7fa 100644
--- a/packages/electron/scripts/rebuild-native.mjs
+++ b/packages/electron/scripts/rebuild-native.mjs
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild';
+import { resolveTargetArchitecture } from './target-architecture.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -16,6 +17,7 @@ const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version;
+const targetArchitecture = resolveTargetArchitecture();
const copyDirectory = async (src, dst) => {
await fsp.mkdir(dst, { recursive: true });
@@ -142,7 +144,7 @@ try {
buildPath: rebuildPath.buildPath,
electronVersion,
force: true,
- arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
+ arch: targetArchitecture.electronBuilder,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
} finally {
diff --git a/packages/electron/scripts/target-architecture.mjs b/packages/electron/scripts/target-architecture.mjs
new file mode 100644
index 0000000000..7bda2d99fa
--- /dev/null
+++ b/packages/electron/scripts/target-architecture.mjs
@@ -0,0 +1,77 @@
+const ARCHITECTURES = {
+ x64: {
+ node: 'x64',
+ electronBuilder: 'x64',
+ opencode: 'x64',
+ },
+ arm64: {
+ node: 'arm64',
+ electronBuilder: 'arm64',
+ opencode: 'arm64',
+ },
+};
+
+const ARCHITECTURE_ALIASES = new Map([
+ ['x64', 'x64'],
+ ['amd64', 'x64'],
+ ['x86_64', 'x64'],
+ ['arm64', 'arm64'],
+ ['aarch64', 'arm64'],
+]);
+
+export const normalizeTargetArchitecture = (value, source = 'target architecture') => {
+ const normalized = ARCHITECTURE_ALIASES.get(String(value || '').trim().toLowerCase());
+ if (!normalized) {
+ throw new Error(
+ `Unsupported ${source} ${JSON.stringify(value)}. Supported architectures: x64, arm64.`,
+ );
+ }
+ return ARCHITECTURES[normalized];
+};
+
+export const readElectronBuilderArchitecture = (args = []) => {
+ const requested = [];
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index];
+ if (argument === '--x64' || argument === '--arm64') requested.push(argument.slice(2));
+ if (argument === '--arch' && args[index + 1]) requested.push(args[index + 1]);
+ if (argument.startsWith('--arch=')) requested.push(argument.slice('--arch='.length));
+ }
+ if (requested.length === 0) return null;
+
+ const architectures = new Set(requested.map((value) => normalizeTargetArchitecture(value, 'electron-builder architecture').node));
+ if (architectures.size !== 1) {
+ throw new Error(`Exactly one Electron target architecture is required, got: ${requested.join(', ')}.`);
+ }
+ return [...architectures][0];
+};
+
+export const resolveTargetArchitecture = ({
+ platform = process.platform,
+ hostArchitecture = process.arch,
+ environment = process.env,
+ builderArgs = [],
+} = {}) => {
+ const host = normalizeTargetArchitecture(hostArchitecture, 'host architecture');
+ const builderArchitecture = readElectronBuilderArchitecture(builderArgs);
+ const requestedValues = [
+ environment.OPENCHAMBER_TARGET_ARCH,
+ environment.ELECTRON_BUILDER_ARCH,
+ builderArchitecture,
+ ].filter(Boolean);
+ const requestedArchitectures = new Set(
+ requestedValues.map((value) => normalizeTargetArchitecture(value, 'target architecture').node),
+ );
+ if (requestedArchitectures.size > 1) {
+ throw new Error(`Conflicting target architectures: ${requestedValues.join(', ')}.`);
+ }
+
+ const target = normalizeTargetArchitecture(requestedValues[0] || host.node);
+ if (platform === 'linux' && target.node !== host.node) {
+ throw new Error(
+ `Linux AppImages must be built natively: host is ${host.node}, target is ${target.node}. `
+ + `Run this build on a ${target.node} Linux host.`,
+ );
+ }
+ return target;
+};
diff --git a/packages/electron/scripts/target-architecture.test.mjs b/packages/electron/scripts/target-architecture.test.mjs
new file mode 100644
index 0000000000..8fd753cf45
--- /dev/null
+++ b/packages/electron/scripts/target-architecture.test.mjs
@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ normalizeTargetArchitecture,
+ readElectronBuilderArchitecture,
+ resolveTargetArchitecture,
+} from './target-architecture.mjs';
+
+test('normalizes host and release architecture aliases', () => {
+ assert.equal(normalizeTargetArchitecture('amd64').node, 'x64');
+ assert.equal(normalizeTargetArchitecture('x86_64').electronBuilder, 'x64');
+ assert.equal(normalizeTargetArchitecture('aarch64').opencode, 'arm64');
+});
+
+test('reads a single electron-builder target architecture', () => {
+ assert.equal(readElectronBuilderArchitecture(['--linux', '--arch=aarch64']), 'arm64');
+ assert.equal(readElectronBuilderArchitecture(['--linux', '--x64']), 'x64');
+});
+
+test('rejects unsupported architectures', () => {
+ assert.throws(() => normalizeTargetArchitecture('ia32'), /Supported architectures: x64, arm64/);
+});
+
+test('rejects conflicting architecture inputs', () => {
+ assert.throws(
+ () => resolveTargetArchitecture({
+ platform: 'linux',
+ hostArchitecture: 'x64',
+ environment: { OPENCHAMBER_TARGET_ARCH: 'x64', ELECTRON_BUILDER_ARCH: 'arm64' },
+ }),
+ /Conflicting target architectures/,
+ );
+});
+
+test('rejects cross-architecture Linux packaging', () => {
+ assert.throws(
+ () => resolveTargetArchitecture({
+ platform: 'linux',
+ hostArchitecture: 'x86_64',
+ environment: { OPENCHAMBER_TARGET_ARCH: 'aarch64' },
+ }),
+ /must be built natively.*host is x64, target is arm64/,
+ );
+});
+
+test('accepts matching native Linux architecture aliases', () => {
+ assert.equal(resolveTargetArchitecture({
+ platform: 'linux',
+ hostArchitecture: 'x64',
+ environment: { OPENCHAMBER_TARGET_ARCH: 'amd64' },
+ }).node, 'x64');
+});
diff --git a/packages/electron/scripts/updater-e2e-fixture.md b/packages/electron/scripts/updater-e2e-fixture.md
new file mode 100644
index 0000000000..3c92447153
--- /dev/null
+++ b/packages/electron/scripts/updater-e2e-fixture.md
@@ -0,0 +1,36 @@
+# Linux Updater E2E Fixture
+
+This local-only harness verifies AppImage N-to-N+1 replacement without changing the
+production GitHub updater provider. It supports native x64 and arm64 hosts.
+
+1. Build both versions on the native target architecture. For N and N+1, set the
+ test-build marker only while bundling main, then complete normal packaging:
+
+ ```bash
+ OPENCHAMBER_TARGET_ARCH=x64 OPENCHAMBER_UPDATER_E2E_BUILD=1 bun run bundle:main
+ OPENCHAMBER_TARGET_ARCH=x64 node ./scripts/package.mjs --linux --x64 --publish=never
+ ```
+
+ Use `OPENCHAMBER_TARGET_ARCH=arm64` and `--arm64` on an arm64 host. Keep the N and
+ N+1 AppImages in separate output directories before rebuilding.
+
+2. Launch N against a loopback fixture containing N+1:
+
+ ```bash
+ bun run updater:e2e:fixture -- run \
+ --arch x64 \
+ --current /absolute/path/OpenChamber-N-linux-x86_64.AppImage \
+ --next /absolute/path/OpenChamber-N+1-linux-x86_64.AppImage \
+ --version N+1 \
+ --dir /tmp/openchamber-updater-e2e
+ ```
+
+3. In N, check for updates, download/install, and restart. Verify the restarted app
+ reports N+1 and that the file at `APPIMAGE` was replaced. Repeat with `--arch arm64`
+ and the arm64 AppImages on the arm64 host.
+
+The harness binds only `127.0.0.1`. Runtime override activation additionally requires
+`OPENCHAMBER_E2E=1`, the loopback URL set by the harness, and the build-time marker.
+Normal packages omit the build-time marker and always use `openchamber/openchamber`.
+The renderer, IPC bridge, command-line arguments, and persistent configuration do not
+have access to the feed URL.
diff --git a/packages/electron/scripts/updater-e2e-fixture.mjs b/packages/electron/scripts/updater-e2e-fixture.mjs
new file mode 100644
index 0000000000..f5580ff826
--- /dev/null
+++ b/packages/electron/scripts/updater-e2e-fixture.mjs
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import http from 'node:http';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const ARCHITECTURES = new Map([
+ ['x64', 'latest-linux.yml'],
+ ['arm64', 'latest-linux-arm64.yml'],
+]);
+
+const usage = `Usage:
+ updater-e2e-fixture.mjs stage --arch --next --version --dir
+ updater-e2e-fixture.mjs serve --dir [--port ]
+ updater-e2e-fixture.mjs run --arch --current --next --version --dir [--port ]
+
+Both AppImages must be packaged with OPENCHAMBER_UPDATER_E2E_BUILD=1 during bundle:main.
+The run command stages N+1, serves it on 127.0.0.1, and launches N with only the two
+runtime E2E gates. Use the Desktop update UI to check, download, apply, and restart.
+Keep this process running until the restarted N+1 is verified, then press Ctrl-C.`;
+
+const parseArguments = (argv) => {
+ const [command, ...rest] = argv;
+ const options = {};
+ for (let index = 0; index < rest.length; index += 2) {
+ const key = rest[index];
+ const value = rest[index + 1];
+ if (!key?.startsWith('--') || value === undefined) throw new Error(usage);
+ options[key.slice(2)] = value;
+ }
+ return { command, options };
+};
+
+const requireOption = (options, name) => {
+ const value = options[name];
+ if (!value) throw new Error(`Missing --${name}\n\n${usage}`);
+ return value;
+};
+
+const resolveArchitecture = (value) => {
+ if (!ARCHITECTURES.has(value)) throw new Error(`Unsupported architecture: ${value || '(missing)'}`);
+ return value;
+};
+
+const resolveExistingFile = (value, name) => {
+ const filePath = path.resolve(value);
+ if (!fs.statSync(filePath).isFile()) throw new Error(`--${name} must be a file: ${filePath}`);
+ return filePath;
+};
+
+const sha512 = (filePath) => crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64');
+
+export const stageUpdaterFixture = ({ architecture, nextAppImage, version, directory }) => {
+ const manifestName = ARCHITECTURES.get(resolveArchitecture(architecture));
+ const sourcePath = resolveExistingFile(nextAppImage, 'next');
+ const feedDirectory = path.resolve(directory);
+ fs.mkdirSync(feedDirectory, { recursive: true });
+ const artifactName = path.basename(sourcePath);
+ const artifactPath = path.join(feedDirectory, artifactName);
+ if (sourcePath !== artifactPath) fs.copyFileSync(sourcePath, artifactPath);
+ const size = fs.statSync(artifactPath).size;
+ const checksum = sha512(artifactPath);
+ const manifest = [
+ `version: ${version}`,
+ 'files:',
+ ` - url: ${encodeURIComponent(artifactName)}`,
+ ` sha512: ${checksum}`,
+ ` size: ${size}`,
+ `path: ${encodeURIComponent(artifactName)}`,
+ `sha512: ${checksum}`,
+ `releaseDate: '${new Date().toISOString()}'`,
+ '',
+ ].join('\n');
+ fs.writeFileSync(path.join(feedDirectory, manifestName), manifest, { mode: 0o644 });
+ return { artifactPath, manifestName, size };
+};
+
+export const createFixtureServer = ({ directory, port = 0 }) => {
+ const feedDirectory = path.resolve(directory);
+ const files = new Map(fs.readdirSync(feedDirectory, { withFileTypes: true })
+ .filter((entry) => entry.isFile())
+ .map((entry) => [`/${encodeURIComponent(entry.name)}`, path.join(feedDirectory, entry.name)]));
+ const server = http.createServer((request, response) => {
+ const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
+ const filePath = files.get(requestUrl.pathname);
+ if ((request.method !== 'GET' && request.method !== 'HEAD') || !filePath) {
+ response.writeHead(404).end();
+ return;
+ }
+ const stat = fs.statSync(filePath);
+ response.writeHead(200, {
+ 'Content-Length': stat.size,
+ 'Content-Type': filePath.endsWith('.yml') ? 'text/yaml' : 'application/octet-stream',
+ });
+ if (request.method === 'HEAD') response.end();
+ else fs.createReadStream(filePath).pipe(response);
+ });
+ return new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(Number(port), '127.0.0.1', () => {
+ const address = server.address();
+ resolve({ server, url: `http://127.0.0.1:${address.port}/` });
+ });
+ });
+};
+
+const waitForSignal = () => new Promise((resolve) => {
+ process.once('SIGINT', resolve);
+ process.once('SIGTERM', resolve);
+});
+
+const main = async () => {
+ const { command, options } = parseArguments(process.argv.slice(2));
+ if (command === '--help' || command === 'help' || !command) {
+ console.log(usage);
+ return;
+ }
+ const directory = requireOption(options, 'dir');
+ if (command === 'stage' || command === 'run') {
+ const result = stageUpdaterFixture({
+ architecture: requireOption(options, 'arch'),
+ nextAppImage: requireOption(options, 'next'),
+ version: requireOption(options, 'version'),
+ directory,
+ });
+ console.log(`[electron] staged ${result.manifestName} and ${path.basename(result.artifactPath)}`);
+ if (command === 'stage') return;
+ }
+ if (command !== 'serve' && command !== 'run') throw new Error(usage);
+ const { server, url } = await createFixtureServer({ directory, port: options.port || 0 });
+ console.log(`[electron] updater E2E fixture listening at ${url}`);
+ if (command === 'run') {
+ const currentAppImage = resolveExistingFile(requireOption(options, 'current'), 'current');
+ const child = spawn(currentAppImage, [], {
+ env: {
+ ...process.env,
+ APPIMAGE: currentAppImage,
+ OPENCHAMBER_E2E: '1',
+ OPENCHAMBER_UPDATER_E2E_URL: url,
+ },
+ stdio: 'inherit',
+ });
+ child.once('error', (error) => console.error(`[electron] failed to launch N AppImage: ${error.message}`));
+ }
+ await waitForSignal();
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
+};
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+ });
+}
diff --git a/packages/electron/scripts/updater-e2e-fixture.test.mjs b/packages/electron/scripts/updater-e2e-fixture.test.mjs
new file mode 100644
index 0000000000..adaf851786
--- /dev/null
+++ b/packages/electron/scripts/updater-e2e-fixture.test.mjs
@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+
+import { createFixtureServer, stageUpdaterFixture } from './updater-e2e-fixture.mjs';
+import { parseUpdateManifest, verifyUpdateManifest } from './verify-update-manifest.mjs';
+
+test('stages architecture-specific generic updater fixtures with valid metadata', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-updater-fixture-'));
+ try {
+ const source = path.join(root, 'OpenChamber-1.15.1-linux-arm64.AppImage');
+ const directory = path.join(root, 'feed');
+ fs.writeFileSync(source, 'fixture-appimage');
+ const result = stageUpdaterFixture({
+ architecture: 'arm64',
+ nextAppImage: source,
+ version: '1.15.1',
+ directory,
+ });
+ assert.equal(result.manifestName, 'latest-linux-arm64.yml');
+ const manifestPath = path.join(directory, result.manifestName);
+ assert.deepEqual(parseUpdateManifest(fs.readFileSync(manifestPath, 'utf8')).files.length, 1);
+ assert.deepEqual(verifyUpdateManifest({
+ manifestPath,
+ artifactPath: result.artifactPath,
+ expectedVersion: '1.15.1',
+ }), {
+ name: 'OpenChamber-1.15.1-linux-arm64.AppImage',
+ size: 16,
+ version: '1.15.1',
+ });
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test('serves only staged fixture files over loopback', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-updater-server-'));
+ const artifact = path.join(root, 'OpenChamber.AppImage');
+ fs.writeFileSync(artifact, 'fixture');
+ const { server, url } = await createFixtureServer({ directory: root });
+ try {
+ assert.equal(new URL(url).hostname, '127.0.0.1');
+ const response = await fetch(`${url}OpenChamber.AppImage`);
+ assert.equal(response.status, 200);
+ assert.equal(await response.text(), 'fixture');
+ assert.equal((await fetch(`${url}../package.json`)).status, 404);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/electron/scripts/verify-linux-appimage.mjs b/packages/electron/scripts/verify-linux-appimage.mjs
new file mode 100644
index 0000000000..97da9a0529
--- /dev/null
+++ b/packages/electron/scripts/verify-linux-appimage.mjs
@@ -0,0 +1,164 @@
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { normalizeTargetArchitecture } from './target-architecture.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const electronRoot = path.resolve(__dirname, '..');
+const workspaceRoot = path.resolve(electronRoot, '../..');
+const ELF_MACHINE = { x64: 62, arm64: 183 };
+// sherpa-onnx-node loads this Node-API addon from its platform-specific prebuilt
+// package in the separate server worker, so verify its architecture here rather
+// than Electron-rebuilding it with the source-built modules.
+const REQUIRED_NATIVE_MODULES = ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node'];
+
+/** electron-builder AppImage arch token: x64 → x86_64, arm64 → arm64 */
+export const linuxAppImageArchSuffix = (architecture) => (
+ architecture === 'x64' ? 'x86_64' : 'arm64'
+);
+
+const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
+
+export const readElfArchitecture = (filePath) => {
+ const header = Buffer.alloc(20);
+ const descriptor = fs.openSync(filePath, 'r');
+ try {
+ if (fs.readSync(descriptor, header, 0, header.length, 0) !== header.length) {
+ throw new Error(`ELF header is truncated: ${filePath}`);
+ }
+ } finally {
+ fs.closeSync(descriptor);
+ }
+ if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
+ throw new Error(`Expected an ELF binary: ${filePath}`);
+ }
+ const byteOrder = header[5];
+ if (byteOrder !== 1 && byteOrder !== 2) throw new Error(`Unsupported ELF byte order: ${filePath}`);
+ const machine = byteOrder === 1 ? header.readUInt16LE(18) : header.readUInt16BE(18);
+ const architecture = Object.entries(ELF_MACHINE).find(([, value]) => value === machine)?.[0];
+ if (!architecture) throw new Error(`Unsupported ELF machine ${machine}: ${filePath}`);
+ return architecture;
+};
+
+export const assertElfArchitecture = (filePath, expectedArchitecture, label) => {
+ if (!fs.existsSync(filePath)) throw new Error(`Missing ${label}: ${filePath}`);
+ const actual = readElfArchitecture(filePath);
+ if (actual !== expectedArchitecture) {
+ throw new Error(`${label} architecture mismatch: expected ${expectedArchitecture}, got ${actual} (${filePath})`);
+ }
+};
+
+const collectFiles = (root, predicate) => {
+ const matches = [];
+ const visit = (directory) => {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const fullPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) visit(fullPath);
+ else if (entry.isFile() && predicate(entry.name, fullPath)) matches.push(fullPath);
+ }
+ };
+ visit(root);
+ return matches;
+};
+
+const defaultCliVersion = (binaryPath) => {
+ const result = spawnSync(binaryPath, ['--version'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout: 15000,
+ });
+ if (result.status !== 0) throw new Error(`Failed to run packaged OpenCode CLI: ${binaryPath}`);
+ return (result.stdout || '').trim().split(/\s+/)[0] || '';
+};
+
+export const verifyExtractedPayload = ({
+ root,
+ targetArchitecture,
+ expectedOpenCodeVersion,
+ runCliVersion = defaultCliVersion,
+}) => {
+ const desktopPath = path.join(root, 'openchamber.desktop');
+ if (!fs.existsSync(desktopPath)) throw new Error(`Missing desktop entry: ${desktopPath}`);
+ const desktop = fs.readFileSync(desktopPath, 'utf8');
+ for (const entry of ['Name=OpenChamber', 'Icon=openchamber', 'StartupWMClass=openchamber']) {
+ if (!desktop.split(/\r?\n/).includes(entry)) throw new Error(`Desktop identity mismatch: missing ${entry}`);
+ }
+ if (!/^Exec=AppRun(?:\s|$)/m.test(desktop)) throw new Error('Desktop identity mismatch: expected AppImage AppRun entrypoint');
+
+ assertElfArchitecture(path.join(root, 'openchamber'), targetArchitecture, 'Electron executable');
+ const cliPath = path.join(root, 'resources', 'opencode-cli', 'opencode');
+ assertElfArchitecture(cliPath, targetArchitecture, 'OpenCode CLI');
+ const actualVersion = runCliVersion(cliPath);
+ if (actualVersion !== expectedOpenCodeVersion) {
+ throw new Error(`OpenCode CLI version mismatch: expected ${expectedOpenCodeVersion}, got ${actualVersion || '(empty)'}`);
+ }
+
+ const unpackedModules = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules');
+ if (!fs.existsSync(unpackedModules)) throw new Error(`Missing unpacked native modules: ${unpackedModules}`);
+ const nativeModules = collectFiles(unpackedModules, (name, fullPath) => {
+ if (!name.endsWith('.node')) return false;
+ const normalizedPath = fullPath.split(path.sep).join('/');
+ if (!normalizedPath.includes('/prebuilds/')) return true;
+ return normalizedPath.includes(`/prebuilds/linux-${targetArchitecture}/`);
+ });
+ for (const requiredName of REQUIRED_NATIVE_MODULES) {
+ if (!nativeModules.some((modulePath) => path.basename(modulePath) === requiredName)) {
+ throw new Error(`Missing packaged native module: ${requiredName}`);
+ }
+ }
+ for (const modulePath of nativeModules) assertElfArchitecture(modulePath, targetArchitecture, 'Native module');
+ return { nativeModuleCount: nativeModules.length, openCodeVersion: actualVersion };
+};
+
+const findAppImage = (version, architecture) => {
+ const suffix = linuxAppImageArchSuffix(architecture);
+ const expected = path.join(electronRoot, 'dist', `OpenChamber-${version}-linux-${suffix}.AppImage`);
+ if (!fs.existsSync(expected)) throw new Error(`Linux AppImage not found: ${expected}`);
+ return expected;
+};
+
+const extractAppImage = (appImagePath, destination) => {
+ fs.chmodSync(appImagePath, fs.statSync(appImagePath).mode | 0o100);
+ const result = spawnSync(appImagePath, ['--appimage-extract'], {
+ cwd: destination,
+ encoding: 'utf8',
+ stdio: ['ignore', 'ignore', 'pipe'],
+ timeout: 120000,
+ });
+ if (result.status !== 0) {
+ throw new Error(`Failed to extract AppImage: ${appImagePath}\n${(result.stderr || '').trim()}`);
+ }
+ return path.join(destination, 'squashfs-root');
+};
+
+const main = () => {
+ const rootPackage = readJson(path.join(workspaceRoot, 'package.json'));
+ const target = normalizeTargetArchitecture(process.env.OPENCHAMBER_TARGET_ARCH || process.arch).node;
+ const appImagePath = process.argv[2] ? path.resolve(process.argv[2]) : findAppImage(rootPackage.version, target);
+ assertElfArchitecture(appImagePath, target, 'AppImage');
+
+ const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-appimage-'));
+ try {
+ const result = verifyExtractedPayload({
+ root: extractAppImage(appImagePath, temporaryDirectory),
+ targetArchitecture: target,
+ expectedOpenCodeVersion: rootPackage.dependencies?.['@opencode-ai/sdk'],
+ });
+ console.log(`[electron] verified Linux ${target} AppImage: ${appImagePath}`);
+ console.log(`[electron] verified OpenCode CLI ${result.openCodeVersion} and ${result.nativeModuleCount} native modules`);
+ } finally {
+ fs.rmSync(temporaryDirectory, { recursive: true, force: true });
+ }
+};
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+ }
+}
diff --git a/packages/electron/scripts/verify-linux-appimage.test.mjs b/packages/electron/scripts/verify-linux-appimage.test.mjs
new file mode 100644
index 0000000000..3ba2084dc0
--- /dev/null
+++ b/packages/electron/scripts/verify-linux-appimage.test.mjs
@@ -0,0 +1,96 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+
+import { linuxAppImageArchSuffix, readElfArchitecture, verifyExtractedPayload } from './verify-linux-appimage.mjs';
+
+const writeElf = (filePath, architecture) => {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ const header = Buffer.alloc(20);
+ header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]);
+ header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18);
+ fs.writeFileSync(filePath, header, { mode: 0o755 });
+};
+
+const createPayload = () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-payload-test-'));
+ fs.writeFileSync(path.join(root, 'openchamber.desktop'), [
+ '[Desktop Entry]', 'Name=OpenChamber', 'Exec=AppRun --no-sandbox %U', 'Icon=openchamber', 'StartupWMClass=openchamber', '',
+ ].join('\n'));
+ writeElf(path.join(root, 'openchamber'), 'x64');
+ writeElf(path.join(root, 'resources/opencode-cli/opencode'), 'x64');
+ for (const name of ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node']) {
+ writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules', name), 'x64');
+ }
+ return root;
+};
+
+test('reads supported ELF architectures', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-elf-test-'));
+ try {
+ writeElf(path.join(root, 'x64'), 'x64');
+ writeElf(path.join(root, 'arm64'), 'arm64');
+ assert.equal(readElfArchitecture(path.join(root, 'x64')), 'x64');
+ assert.equal(readElfArchitecture(path.join(root, 'arm64')), 'arm64');
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test('AppImage artifact names use electron-builder arch suffixes', () => {
+ assert.equal(linuxAppImageArchSuffix('x64'), 'x86_64');
+ assert.equal(linuxAppImageArchSuffix('arm64'), 'arm64');
+});
+
+test('verifies identity, version, and native payload architecture', () => {
+ const root = createPayload();
+ try {
+ const result = verifyExtractedPayload({
+ root,
+ targetArchitecture: 'x64',
+ expectedOpenCodeVersion: '1.17.18',
+ runCliVersion: () => '1.17.18',
+ });
+ assert.equal(result.nativeModuleCount, 3);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test('fails on a missing native module', () => {
+ const root = createPayload();
+ try {
+ fs.rmSync(path.join(root, 'resources/app.asar.unpacked/node_modules/pty.node'));
+ assert.throws(() => verifyExtractedPayload({
+ root,
+ targetArchitecture: 'x64',
+ expectedOpenCodeVersion: '1.17.18',
+ runCliVersion: () => '1.17.18',
+ }), /Missing packaged native module: pty\.node/);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test('fails on wrong CLI version or native architecture', () => {
+ const root = createPayload();
+ try {
+ assert.throws(() => verifyExtractedPayload({
+ root,
+ targetArchitecture: 'x64',
+ expectedOpenCodeVersion: '1.17.18',
+ runCliVersion: () => '1.17.17',
+ }), /OpenCode CLI version mismatch/);
+ writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules/pty.node'), 'arm64');
+ assert.throws(() => verifyExtractedPayload({
+ root,
+ targetArchitecture: 'x64',
+ expectedOpenCodeVersion: '1.17.18',
+ runCliVersion: () => '1.17.18',
+ }), /Native module architecture mismatch/);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/electron/scripts/verify-update-manifest.mjs b/packages/electron/scripts/verify-update-manifest.mjs
new file mode 100644
index 0000000000..aca446ddc7
--- /dev/null
+++ b/packages/electron/scripts/verify-update-manifest.mjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const parseUpdateManifest = (content) => {
+ const version = content.match(/^version:\s*(\S+)\s*$/m)?.[1] || '';
+ const lines = content.split(/\r?\n/);
+ const files = [];
+ let entry = null;
+ for (const line of lines) {
+ const start = line.match(/^\s{2}-\s+(url|sha512|size|blockMapSize):\s*(\S+)\s*$/);
+ const field = start || line.match(/^\s{4}(url|sha512|size|blockMapSize):\s*(\S+)\s*$/);
+ if (start) {
+ if (entry) files.push(entry);
+ entry = {};
+ }
+ if (!field || !entry) continue;
+ const [, key, value] = field;
+ entry[key] = key === 'size' || key === 'blockMapSize' ? Number(value) : value;
+ }
+ if (entry) files.push(entry);
+ return {
+ version,
+ files: files.filter((file) => file.url && file.sha512 && Number.isSafeInteger(file.size)),
+ };
+};
+
+export const verifyUpdateManifest = ({ manifestPath, artifactPath, expectedVersion }) => {
+ const manifest = parseUpdateManifest(fs.readFileSync(manifestPath, 'utf8'));
+ const expectedName = path.basename(artifactPath);
+ if (manifest.version !== expectedVersion) {
+ throw new Error(`Update manifest version mismatch: expected ${expectedVersion}, got ${manifest.version || '(missing)'}`);
+ }
+ if (manifest.files.length !== 1) {
+ throw new Error(`Linux update manifest must contain exactly one artifact, got ${manifest.files.length}`);
+ }
+ const [entry] = manifest.files;
+ if (decodeURIComponent(path.basename(entry.url)) !== expectedName) {
+ throw new Error(`Update manifest artifact mismatch: expected ${expectedName}, got ${entry.url}`);
+ }
+ const bytes = fs.readFileSync(artifactPath);
+ if (entry.size !== bytes.length) {
+ throw new Error(`Update manifest size mismatch: expected ${bytes.length}, got ${entry.size}`);
+ }
+ const checksum = crypto.createHash('sha512').update(bytes).digest('base64');
+ if (entry.sha512 !== checksum) throw new Error('Update manifest sha512 mismatch');
+ return { name: expectedName, size: bytes.length, version: manifest.version };
+};
+
+const main = () => {
+ const [manifestPath, artifactPath, expectedVersion] = process.argv.slice(2);
+ if (!manifestPath || !artifactPath || !expectedVersion) {
+ throw new Error('Usage: verify-update-manifest.mjs ');
+ }
+ const result = verifyUpdateManifest({
+ manifestPath: path.resolve(manifestPath),
+ artifactPath: path.resolve(artifactPath),
+ expectedVersion,
+ });
+ console.log(`[electron] verified ${path.basename(manifestPath)} for ${result.name} (${result.size} bytes)`);
+};
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+ }
+}
diff --git a/packages/electron/scripts/verify-update-manifest.test.mjs b/packages/electron/scripts/verify-update-manifest.test.mjs
new file mode 100644
index 0000000000..d880b79c19
--- /dev/null
+++ b/packages/electron/scripts/verify-update-manifest.test.mjs
@@ -0,0 +1,74 @@
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+
+import { verifyUpdateManifest } from './verify-update-manifest.mjs';
+
+const fixture = (manifestName, artifactName, fields) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-manifest-test-'));
+ const artifactPath = path.join(root, artifactName);
+ const manifestPath = path.join(root, manifestName);
+ const bytes = Buffer.from(`artifact:${artifactName}`);
+ fs.writeFileSync(artifactPath, bytes);
+ fs.writeFileSync(manifestPath, [
+ 'version: 1.15.0',
+ 'files:',
+ ...(fields || [
+ ` - url: ${artifactName}`,
+ ` sha512: ${crypto.createHash('sha512').update(bytes).digest('base64')}`,
+ ` size: ${bytes.length}`,
+ ]),
+ `path: ${artifactName}`,
+ 'releaseDate: 2026-07-10T00:00:00.000Z',
+ '',
+ ].join('\n'));
+ return { root, artifactPath, manifestPath };
+};
+
+for (const [manifestName, artifactName] of [
+ ['latest-linux.yml', 'OpenChamber-1.15.0-linux-x86_64.AppImage'],
+ ['latest-linux-arm64.yml', 'OpenChamber-1.15.0-linux-arm64.AppImage'],
+]) {
+ test(`validates architecture-specific ${manifestName}`, () => {
+ const value = fixture(manifestName, artifactName);
+ try {
+ assert.equal(verifyUpdateManifest({ ...value, expectedVersion: '1.15.0' }).name, artifactName);
+ } finally {
+ fs.rmSync(value.root, { recursive: true, force: true });
+ }
+ });
+}
+
+test('accepts electron-builder field ordering and optional blockMapSize', () => {
+ const artifactName = 'OpenChamber-1.15.0-linux-x86_64.AppImage';
+ const bytes = Buffer.from(`artifact:${artifactName}`);
+ const value = fixture('latest-linux.yml', artifactName, [
+ ` - sha512: ${crypto.createHash('sha512').update(bytes).digest('base64')}`,
+ ` size: ${bytes.length}`,
+ ' blockMapSize: 1234',
+ ` url: ${artifactName}`,
+ ]);
+ try {
+ assert.equal(verifyUpdateManifest({ ...value, expectedVersion: '1.15.0' }).name, artifactName);
+ } finally {
+ fs.rmSync(value.root, { recursive: true, force: true });
+ }
+});
+
+test('rejects a manifest that points at the other architecture artifact', () => {
+ const value = fixture('latest-linux-arm64.yml', 'OpenChamber-1.15.0-linux-arm64.AppImage');
+ try {
+ const x64Artifact = path.join(value.root, 'OpenChamber-1.15.0-linux-x86_64.AppImage');
+ fs.copyFileSync(value.artifactPath, x64Artifact);
+ assert.throws(() => verifyUpdateManifest({
+ manifestPath: value.manifestPath,
+ artifactPath: x64Artifact,
+ expectedVersion: '1.15.0',
+ }), /artifact mismatch/);
+ } finally {
+ fs.rmSync(value.root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/electron/updater-capability.mjs b/packages/electron/updater-capability.mjs
new file mode 100644
index 0000000000..049b247132
--- /dev/null
+++ b/packages/electron/updater-capability.mjs
@@ -0,0 +1,35 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+export const assertUpdaterCapability = ({
+ platform = process.platform,
+ packaged,
+ appImagePath = process.env.APPIMAGE,
+ access = fs.accessSync,
+ stat = fs.statSync,
+} = {}) => {
+ if (platform !== 'linux' || !packaged) return;
+
+ if (!appImagePath) {
+ throw new Error(
+ 'Updates require the packaged Linux AppImage. Start OpenChamber from its .AppImage file, not an extracted or repackaged copy.',
+ );
+ }
+ if (!path.isAbsolute(appImagePath)) {
+ throw new Error(`Updates require APPIMAGE to be an absolute path, got: ${appImagePath}`);
+ }
+
+ try {
+ if (!stat(appImagePath).isFile()) throw new Error('not a file');
+ } catch {
+ throw new Error(`The running AppImage cannot be found at ${appImagePath}. Start OpenChamber from a valid .AppImage file.`);
+ }
+
+ try {
+ access(appImagePath, fs.constants.W_OK);
+ } catch {
+ throw new Error(
+ `The AppImage is not writable at ${appImagePath}. Move it to a writable location or grant write permission before updating.`,
+ );
+ }
+};
diff --git a/packages/electron/updater-capability.test.mjs b/packages/electron/updater-capability.test.mjs
new file mode 100644
index 0000000000..b612210b07
--- /dev/null
+++ b/packages/electron/updater-capability.test.mjs
@@ -0,0 +1,49 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { assertUpdaterCapability } from './updater-capability.mjs';
+
+test('preserves updater behavior outside packaged Linux', () => {
+ assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'darwin', packaged: true }));
+ assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'win32', packaged: true }));
+ assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'linux', packaged: false }));
+});
+
+test('rejects packaged Linux execution outside an AppImage', () => {
+ assert.throws(
+ () => assertUpdaterCapability({ platform: 'linux', packaged: true, appImagePath: '' }),
+ /Start OpenChamber from its \.AppImage file/,
+ );
+});
+
+test('rejects missing and non-writable AppImages with actionable errors', () => {
+ assert.throws(
+ () => assertUpdaterCapability({
+ platform: 'linux',
+ packaged: true,
+ appImagePath: '/opt/OpenChamber.AppImage',
+ stat: () => { throw new Error('missing'); },
+ }),
+ /cannot be found.*valid \.AppImage file/,
+ );
+ assert.throws(
+ () => assertUpdaterCapability({
+ platform: 'linux',
+ packaged: true,
+ appImagePath: '/opt/OpenChamber.AppImage',
+ stat: () => ({ isFile: () => true }),
+ access: () => { throw new Error('read-only'); },
+ }),
+ /not writable.*grant write permission/,
+ );
+});
+
+test('accepts a writable packaged AppImage', () => {
+ assert.doesNotThrow(() => assertUpdaterCapability({
+ platform: 'linux',
+ packaged: true,
+ appImagePath: '/home/user/OpenChamber.AppImage',
+ stat: () => ({ isFile: () => true }),
+ access: () => {},
+ }));
+});
diff --git a/packages/electron/updater-check.mjs b/packages/electron/updater-check.mjs
new file mode 100644
index 0000000000..77b80c6376
--- /dev/null
+++ b/packages/electron/updater-check.mjs
@@ -0,0 +1,42 @@
+const MISSING_UPDATE_FEED_RE =
+ /404|ENOTFOUND|Cannot find (?:channel|latest)|latest-linux(?:-arm64)?\.yml|HttpError:\s*404|status code 404/i;
+
+export const isMissingUpdateFeedError = (error) => {
+ const message = error instanceof Error ? error.message : String(error ?? '');
+ return MISSING_UPDATE_FEED_RE.test(message);
+};
+
+export const checkForDesktopUpdate = async ({ autoUpdater, currentVersion, pendingUpdate, compareVersions }) => {
+ let updateResult;
+ try {
+ updateResult = await autoUpdater.checkForUpdates();
+ } catch (error) {
+ // Before the first Linux (or platform) release publishes its feed, electron-updater
+ // returns 404 for latest-*.yml. Treat that as authoritative "no update" instead of
+ // surfacing a hard failure that looks like a broken updater.
+ if (isMissingUpdateFeedError(error)) {
+ return {
+ available: false,
+ updateInfo: null,
+ updateResult: null,
+ nextVersion: currentVersion,
+ pendingUpdate: null,
+ };
+ }
+ const detail = error instanceof Error && error.message ? `: ${error.message}` : '';
+ throw new Error(`Unable to check for updates${detail}. Check your network connection and try again.`, { cause: error });
+ }
+
+ const updateInfo = updateResult?.updateInfo;
+ const nextVersion =
+ (typeof updateInfo?.version === 'string' && updateInfo.version) ||
+ currentVersion;
+ const available = compareVersions(nextVersion, currentVersion) > 0;
+ return {
+ available,
+ updateInfo,
+ updateResult,
+ nextVersion,
+ pendingUpdate: available ? { version: nextVersion, electronUpdate: updateResult } : null,
+ };
+};
diff --git a/packages/electron/updater-check.test.mjs b/packages/electron/updater-check.test.mjs
new file mode 100644
index 0000000000..430c726464
--- /dev/null
+++ b/packages/electron/updater-check.test.mjs
@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { checkForDesktopUpdate } from './updater-check.mjs';
+
+const compareVersions = (left, right) => left.localeCompare(right, undefined, { numeric: true });
+
+test('signals failed checks without replacing an existing pending update', async () => {
+ const pendingUpdate = { version: '2.0.0', electronUpdate: { id: 'existing' } };
+ await assert.rejects(
+ checkForDesktopUpdate({
+ autoUpdater: { checkForUpdates: async () => { throw new Error('feed unavailable'); } },
+ currentVersion: '1.0.0',
+ pendingUpdate,
+ compareVersions,
+ }),
+ /Unable to check for updates: feed unavailable.*network connection/,
+ );
+ assert.deepEqual(pendingUpdate, { version: '2.0.0', electronUpdate: { id: 'existing' } });
+});
+
+test('treats missing update feed (404) as no update available', async () => {
+ const result = await checkForDesktopUpdate({
+ autoUpdater: {
+ checkForUpdates: async () => {
+ throw new Error('HttpError: 404 Not Found "https://github.com/.../latest-linux.yml"');
+ },
+ },
+ currentVersion: '1.15.0',
+ pendingUpdate: { version: '1.16.0' },
+ compareVersions,
+ });
+ assert.equal(result.available, false);
+ assert.equal(result.pendingUpdate, null);
+ assert.equal(result.nextVersion, '1.15.0');
+});
+
+test('authoritative no-update result clears pending update', async () => {
+ const result = await checkForDesktopUpdate({
+ autoUpdater: { checkForUpdates: async () => ({ updateInfo: { version: '1.0.0' } }) },
+ currentVersion: '1.0.0',
+ pendingUpdate: { version: '2.0.0' },
+ compareVersions,
+ });
+ assert.equal(result.available, false);
+ assert.equal(result.pendingUpdate, null);
+});
diff --git a/packages/electron/updater-feed.mjs b/packages/electron/updater-feed.mjs
new file mode 100644
index 0000000000..8fb9aed8c8
--- /dev/null
+++ b/packages/electron/updater-feed.mjs
@@ -0,0 +1,47 @@
+import fs from 'node:fs';
+
+export const PRODUCTION_UPDATER_FEED = Object.freeze({
+ provider: 'github',
+ owner: 'openchamber',
+ repo: 'openchamber',
+});
+
+const isLoopbackHostname = (hostname) => {
+ if (hostname === '::1' || hostname === '[::1]') return true;
+ const octets = hostname.split('.');
+ if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) return false;
+ const values = octets.map(Number);
+ return values[0] === 127 && values.every((value) => value <= 255);
+};
+
+export const parseLoopbackUpdaterUrl = (value) => {
+ if (!value) return null;
+ try {
+ const url = new URL(value);
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:')
+ || !isLoopbackHostname(url.hostname)
+ || url.username
+ || url.password
+ || url.search
+ || url.hash) {
+ return null;
+ }
+ return url.toString();
+ } catch {
+ return null;
+ }
+};
+
+export const resolveUpdaterFeed = ({
+ environment = process.env,
+ testBuild = false,
+} = {}) => {
+ if (environment.OPENCHAMBER_E2E !== '1'
+ || testBuild !== true) {
+ return PRODUCTION_UPDATER_FEED;
+ }
+
+ const url = parseLoopbackUpdaterUrl(environment.OPENCHAMBER_UPDATER_E2E_URL);
+ if (!url) return PRODUCTION_UPDATER_FEED;
+ return { provider: 'generic', url };
+};
diff --git a/packages/electron/updater-feed.test.mjs b/packages/electron/updater-feed.test.mjs
new file mode 100644
index 0000000000..bee1268344
--- /dev/null
+++ b/packages/electron/updater-feed.test.mjs
@@ -0,0 +1,74 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ PRODUCTION_UPDATER_FEED,
+ parseLoopbackUpdaterUrl,
+ resolveUpdaterFeed,
+} from './updater-feed.mjs';
+
+const overrideEnvironment = {
+ OPENCHAMBER_E2E: '1',
+ OPENCHAMBER_UPDATER_E2E_URL: 'http://127.0.0.1:49152/updates/',
+};
+
+test('production updater feed is immutable GitHub configuration', () => {
+ assert.equal(Object.isFrozen(PRODUCTION_UPDATER_FEED), true);
+ assert.deepEqual(PRODUCTION_UPDATER_FEED, {
+ provider: 'github',
+ owner: 'openchamber',
+ repo: 'openchamber',
+ });
+});
+
+test('requires the complete E2E environment and embedded build-marker conjunction', () => {
+ const cases = [
+ {},
+ { environment: overrideEnvironment },
+ { environment: { OPENCHAMBER_E2E: '1' }, testBuild: true },
+ {
+ environment: { OPENCHAMBER_UPDATER_E2E_URL: overrideEnvironment.OPENCHAMBER_UPDATER_E2E_URL },
+ testBuild: true,
+ },
+ { environment: overrideEnvironment, testBuild: false },
+ ];
+ for (const input of cases) assert.equal(resolveUpdaterFeed(input), PRODUCTION_UPDATER_FEED);
+});
+
+test('accepts only credential-free loopback HTTP(S) URLs', () => {
+ assert.equal(parseLoopbackUpdaterUrl('http://127.0.0.1:8080/feed'), 'http://127.0.0.1:8080/feed');
+ assert.equal(parseLoopbackUpdaterUrl('https://127.255.0.1/feed/'), 'https://127.255.0.1/feed/');
+ assert.equal(parseLoopbackUpdaterUrl('http://[::1]:8080/feed'), 'http://[::1]:8080/feed');
+
+ for (const value of [
+ 'http://localhost:8080/feed',
+ 'http://0.0.0.0:8080/feed',
+ 'http://192.168.1.5:8080/feed',
+ 'https://example.com/feed',
+ 'file:///tmp/feed',
+ 'ftp://127.0.0.1/feed',
+ 'http://user:secret@127.0.0.1/feed',
+ 'http://127.0.0.1/feed?token=secret',
+ 'http://127.0.0.1/feed#fragment',
+ 'not-a-url',
+ ]) assert.equal(parseLoopbackUpdaterUrl(value), null, value);
+});
+
+test('uses a generic feed only when every test-only gate is valid', () => {
+ assert.deepEqual(resolveUpdaterFeed({
+ environment: overrideEnvironment,
+ testBuild: true,
+ }), {
+ provider: 'generic',
+ url: 'http://127.0.0.1:49152/updates/',
+ });
+});
+
+test('invalid URLs fall back to the production feed even with both test gates', () => {
+ for (const url of ['https://example.com/feed', 'http://localhost/feed', '']) {
+ assert.equal(resolveUpdaterFeed({
+ environment: { ...overrideEnvironment, OPENCHAMBER_UPDATER_E2E_URL: url },
+ testBuild: true,
+ }), PRODUCTION_UPDATER_FEED);
+ }
+});
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 7a2199bd79..915e5a7118 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@openchamber/ui",
- "version": "1.15.0",
+ "version": "1.16.0",
"private": true,
"type": "module",
"main": "src/main.tsx",
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 9159a9e00a..7ec3db74f5 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -54,6 +54,7 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
+import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { SyncAppEffects } from '@/apps/AppEffects';
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
@@ -117,15 +118,11 @@ const normalizeEmbeddedDirectory = (value: string | null | undefined): string =>
};
const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
- if (typeof window === 'undefined') {
+ if (typeof window === 'undefined' || !isEmbeddedSessionChat()) {
return null;
}
const params = new URLSearchParams(window.location.search);
- if (params.get('ocPanel') !== 'session-chat') {
- return null;
- }
-
const sessionIdRaw = params.get('sessionId');
const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : '';
if (!sessionId) {
@@ -171,7 +168,12 @@ const EmbeddedSessionChatContent: React.FC<{
if (expectedDirectory && activeDirectory !== expectedDirectory) return;
const bootstrapKey = `${expectedDirectory}\n${embeddedSessionChat.sessionId}`;
- if (bootstrapKeyRef.current === bootstrapKey && currentSessionId === embeddedSessionChat.sessionId) {
+ // Skip if this session was already bootstrapped and a session is still
+ // active — allows in-place navigation (e.g. "Open subtask") to change
+ // currentSessionId without this effect forcing it back. Only re-bootstrap
+ // when currentSessionId was cleared (store init, draft, delete/archive,
+ // runtime-switch remount).
+ if (bootstrapKeyRef.current === bootstrapKey && currentSessionId) {
return;
}
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx
index bc67b397c0..4e37793488 100644
--- a/packages/ui/src/apps/MobileApp.tsx
+++ b/packages/ui/src/apps/MobileApp.tsx
@@ -57,10 +57,11 @@ import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-contex
import { SyncAppEffects } from './AppEffects';
import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface';
+import { BusyDots } from '@/components/chat/message/parts/BusyDots';
import { MobileSessionsSheet } from './MobileSessionsSheet';
import { MobileSurfaceShell } from './MobileSurfaceShell';
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
-import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
+import { autoConnectLastInstance, connectionDisplayUrl, getAutoConnectTargetLabel, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
@@ -981,10 +982,13 @@ const MobileInstancesSurface: React.FC<{
const saveInstance = React.useCallback((event: React.FormEvent) => {
event.preventDefault();
- void saveConnection({ url, label, clientToken }).then((saved) => {
+ // The id is what makes this an EDIT: saveConnection uses it to preserve the
+ // existing relay/https candidates (and the Keychain token they key) instead
+ // of rebuilding the instance from the single URL field.
+ void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => {
if (saved) resetForm();
});
- }, [clientToken, label, resetForm, saveConnection, url]);
+ }, [clientToken, editingId, label, resetForm, saveConnection, url]);
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
@@ -2688,6 +2692,9 @@ export function MobileApp({ apis }: MobileAppProps) {
// splash so we don't flash the connect screen; 'done' means we either connected or
// exhausted the attempt (then the connect screen shows).
const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending');
+ // The instance the splash says we are connecting to. Read once on mount —
+ // auto-connect targets the most-recent saved connection from the same list.
+ const autoConnectLabel = React.useMemo(() => getAutoConnectTargetLabel(), []);
// Bumped to force a re-render (and thus a fresh `sdk` prop for SyncProvider)
// after a same-device transport swap — reconnects the sync layer in place with
// no remount. The value itself is unused; only the re-render matters.
@@ -3052,8 +3059,19 @@ export function MobileApp({ apis }: MobileAppProps) {
// (no saved instance, unreachable, or needs re-login).
if (autoConnectPhase !== 'done') {
return (
-
+
+ {/* Absolutely positioned below the (still perfectly centered) logo so
+ the text never pushes it up. 50% + half the 120px logo + a gap. */}
+ {autoConnectLabel ? (
+
+
{t('mobile.connect.splash.connectingTo')}
+
+ {autoConnectLabel}
+
+
+
+ ) : null}
);
}
diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts
index 4921016222..697acf9a77 100644
--- a/packages/ui/src/apps/mobileConnections.ts
+++ b/packages/ui/src/apps/mobileConnections.ts
@@ -21,7 +21,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n';
import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/connectionPayload';
import { isCapacitorApp } from '@/lib/platform';
-import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
+import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
@@ -406,60 +406,87 @@ const RELAY_CONNECT_TIMEOUT_MS = 15_000;
type RelayProbeOutcome = 'ok' | 'needs-login' | 'auth-failed' | 'unreachable';
-// Probe /health + /auth/session through a short-lived tunnel — the relay
-// counterpart of the direct flow's pre-switch reachability/auth probe. The
-// throwaway client is always closed; the long-lived runtime tunnel is created
-// by switchRuntimeEndpoint afterwards. Cookies never ride the tunnel, so the
-// cookie-only-session special case from the direct flow does not apply here.
+type RelayProbeResult = {
+ outcome: RelayProbeOutcome;
+ // The live tunnel on 'ok' when the caller asked to keep it (adopted as the
+ // runtime tunnel by switchToTransport, saving a second connect+handshake).
+ tunnel?: ReturnType;
+};
+
+// Probe /auth/session through a tunnel — the relay counterpart of the direct
+// flow's pre-switch reachability/auth probe. No /health round-trip: the E2EE
+// handshake already proves the host's identity (only the paired server owns
+// the private key for the pinned hostEncPubJwk), and /auth/session proves both
+// liveness and token validity in one request. Cookies never ride the tunnel,
+// so the cookie-only-session special case from the direct flow does not apply.
+// With `keepTunnel`, an 'ok' result RETURNS the open tunnel (caller owns it);
+// every other path closes it.
const probeRelaySession = async (
relay: MobileRelayConfig,
token?: string,
grant?: string,
timeoutMs: number = RELAY_CONNECT_TIMEOUT_MS,
-): Promise => {
+ options?: { keepTunnel?: boolean },
+): Promise => {
const tunnel = createRelayTunnelClient({
relayUrl: relay.relayUrl,
serverId: relay.serverId,
hostEncPubJwk: relay.hostEncPubJwk,
...(grant ? { grant } : {}),
});
+ const finish = (outcome: RelayProbeOutcome): RelayProbeResult => {
+ if (outcome === 'ok' && options?.keepTunnel) return { outcome, tunnel };
+ tunnel.close();
+ return { outcome };
+ };
try {
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
- const health = await raceWithTimeout(timeoutMs, tunnel.fetch('/health', { headers }).catch(() => null));
- logConnect('relay:health', { ok: health?.ok === true, status: health?.status ?? null });
- if (!health?.ok) return 'unreachable';
const session = await raceWithTimeout(timeoutMs, tunnel.fetch('/auth/session', { headers }).catch(() => null));
logConnect('relay:session', { ok: session?.ok === true, status: session?.status ?? null, hasToken: Boolean(token) });
- if (!session) return 'unreachable';
- if (session.status === 401) return token ? 'auth-failed' : 'needs-login';
- if (!session.ok && session.status !== 404) return 'auth-failed';
+ if (!session) return finish('unreachable');
+ if (session.status === 401) return finish(token ? 'auth-failed' : 'needs-login');
+ if (!session.ok && session.status !== 404) return finish('auth-failed');
const status = await readSessionStatus(session);
if (status && status.disabled !== true && status.authenticated === false) {
- return token ? 'auth-failed' : 'needs-login';
+ return finish(token ? 'auth-failed' : 'needs-login');
}
- return 'ok';
- } finally {
+ return finish('ok');
+ } catch (error) {
tunnel.close();
+ throw error;
}
};
-const switchToRelayRuntime = (relay: MobileRelayConfig, clientToken: string | null, grant?: string, runtimeKey?: string): void => {
+const switchToRelayRuntime = (
+ relay: MobileRelayConfig,
+ clientToken: string | null,
+ grant?: string,
+ runtimeKey?: string,
+ liveTunnel?: ReturnType,
+): void => {
// Relay mode has no network base URL: runtimeFetch intercepts runtime paths on
// the current window origin and rides the E2EE tunnel, so the window origin is
// the correct virtual API base. The runtime key carries the real device
// identity (stable across a device's transports so LAN⇄relay is not treated
// as an instance switch).
const apiBaseUrl = typeof window !== 'undefined' ? window.location.origin : '';
+ const descriptor = {
+ relayUrl: relay.relayUrl,
+ serverId: relay.serverId,
+ hostEncPubJwk: relay.hostEncPubJwk,
+ ...(grant ? { grant } : {}),
+ };
+ // Adopt the probe/redeem tunnel as the runtime tunnel BEFORE the switch: the
+ // activate call inside switchRuntimeEndpoint sees an equal descriptor and
+ // reuses it, skipping a second WebSocket connect + E2EE handshake.
+ if (liveTunnel) {
+ adoptRelayTunnel(descriptor, liveTunnel);
+ }
switchRuntimeEndpoint({
apiBaseUrl,
clientToken,
runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay),
- relay: {
- relayUrl: relay.relayUrl,
- serverId: relay.serverId,
- hostEncPubJwk: relay.hostEncPubJwk,
- ...(grant ? { grant } : {}),
- },
+ relay: descriptor,
});
};
@@ -712,52 +739,159 @@ export const deleteMobileConnection = async (id: string): Promise };
type ProbeResult =
| { status: 'ok'; transport: ChosenTransport }
| { status: 'needs-login' }
| { status: 'unreachable' };
-// Probe a saved device's candidates IN ORDER with its bearer token and return
-// the first transport that is both reachable AND accepts the token. This is the
-// heart of "one device, many transports": at home the LAN candidate answers; away
-// it is unreachable so we fall through to relay — no re-pairing. An explicit auth
-// rejection (401 / authenticated:false) applies to every transport (same token),
-// so it short-circuits to needs-login; a merely unreachable candidate is skipped.
+// How long the direct (LAN/tunnel) candidates keep the track to themselves
+// before the relay probe starts. At home a live LAN answers well inside this
+// window, so nothing changes there; with a dead/stale LAN candidate the relay
+// probe is already mid-flight instead of queued behind the full direct timeout
+// (which alone cost up to MOBILE_CONNECT_TIMEOUT_MS per stale address).
+const RELAY_RACE_HEADSTART_MS = 1_500;
+
+// Probe a saved device's candidates and return the first transport that is both
+// reachable AND accepts the token. This is the heart of "one device, many
+// transports": at home the LAN candidate answers; away it is unreachable so we
+// fall through to relay — no re-pairing. Direct candidates are probed in order
+// and keep priority; the relay probe races them after a short headstart instead
+// of waiting for every direct timeout. An explicit auth rejection (401 /
+// authenticated:false) applies to every transport (same token), so it
+// short-circuits to needs-login; a merely unreachable candidate is skipped.
const probeConnectionCandidates = async (
candidates: MobileTransportCandidate[],
token: string | undefined,
options?: { fast?: boolean },
): Promise => {
const requestOptions = options?.fast ? { totalTimeoutMs: MOBILE_FAST_PROBE_TIMEOUT_MS } : undefined;
- for (const candidate of candidates) {
- if (candidate.kind === 'relay') {
- const outcome = await probeRelaySession(candidate.relay, token, undefined, options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined);
- if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: candidate.relay } };
- if (outcome === 'needs-login' || outcome === 'auth-failed') return { status: 'needs-login' };
- continue; // unreachable → try the next candidate
+ // Identity gate for direct probes: when the device knows its server's identity
+ // (via its relay pairing), a direct candidate must report the SAME serverId in
+ // /health before we send the bearer token to it — a re-assigned LAN address may
+ // now belong to a different machine. Older servers omit serverId from /health;
+ // the gate only rejects an explicit mismatch (matching today's behavior otherwise).
+ const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null;
+ const relayCandidate = candidates.find((c): c is Extract => c.kind === 'relay') ?? null;
+ const directList = candidates.filter((c): c is Extract => c.kind === 'direct');
+
+ const probeDirectChain = async (): Promise => {
+ for (const candidate of directList) {
+ const url = normalizeConnectionUrl(candidate.url) || candidate.url;
+ const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
+ // /health is unauthenticated by design — never send the bearer token to an
+ // address whose identity has not been checked yet.
+ const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
+ if (!health?.ok) continue;
+ if (expectedServerId) {
+ const payload = await health.json().catch(() => null);
+ const reported = payload && typeof payload === 'object' ? (payload as Record).serverId : null;
+ if (typeof reported === 'string' && reported && reported !== expectedServerId) {
+ logConnect('probe:server-id-mismatch', { url });
+ continue;
+ }
+ }
+ const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions);
+ if (session?.status === 401) return { status: 'needs-login' };
+ if (!session || (!session.ok && session.status !== 404)) continue;
+ const status = await readSessionStatus(session);
+ if (status && status.disabled !== true && status.authenticated === false) return { status: 'needs-login' };
+ // A cookie-only native session (authenticated, but not a `client` bearer scope
+ // and not auth-disabled) is not enough — the native runtime transport needs a
+ // bearer token, so fall through to the password flow to mint one.
+ const authDisabled = status?.disabled === true;
+ if (!token && isCapacitorApp() && !authDisabled && status?.scope !== 'client') return { status: 'needs-login' };
+ return { status: 'ok', transport: { kind: 'direct', url } };
}
- const url = normalizeConnectionUrl(candidate.url) || candidate.url;
- const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
- const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }, requestOptions);
- if (!health?.ok) continue;
- const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions);
- if (session?.status === 401) return { status: 'needs-login' };
- if (!session || (!session.ok && session.status !== 404)) continue;
- const status = await readSessionStatus(session);
- if (status && status.disabled !== true && status.authenticated === false) return { status: 'needs-login' };
- // A cookie-only native session (authenticated, but not a `client` bearer scope
- // and not auth-disabled) is not enough — the native runtime transport needs a
- // bearer token, so fall through to the password flow to mint one.
- const authDisabled = status?.disabled === true;
- if (!token && isCapacitorApp() && !authDisabled && status?.scope !== 'client') return { status: 'needs-login' };
- return { status: 'ok', transport: { kind: 'direct', url } };
- }
- return { status: 'unreachable' };
+ return { status: 'unreachable' };
+ };
+
+ const probeRelay = async (): Promise => {
+ if (!relayCandidate) return { status: 'unreachable' };
+ // keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
+ // which adopts it as the runtime tunnel — no second connect + handshake.
+ const { outcome, tunnel } = await probeRelaySession(
+ relayCandidate.relay,
+ token,
+ undefined,
+ options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined,
+ { keepTunnel: true },
+ );
+ if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } };
+ if (outcome === 'needs-login' || outcome === 'auth-failed') return { status: 'needs-login' };
+ return { status: 'unreachable' };
+ };
+
+ if (!relayCandidate) return probeDirectChain();
+ if (directList.length === 0) return probeRelay();
+
+ // Race: direct keeps its priority via the headstart; the loser's work is
+ // discarded (an unused relay tunnel is closed, a late direct success is
+ // reconciled later by reprobe/candidate-refresh which already prefer direct).
+ return new Promise((resolve) => {
+ let settled = false;
+ let relayCancelled = false;
+ let headstartTimer: number | undefined;
+ let directResult: ProbeResult | null = null;
+ let relayResult: ProbeResult | null = null;
+
+ const closeUnusedRelayTunnel = (result: ProbeResult | null) => {
+ if (result?.status === 'ok' && result.transport.kind === 'relay') result.transport.tunnel?.close();
+ };
+ const finish = (result: ProbeResult) => {
+ if (settled) return;
+ settled = true;
+ resolve(result);
+ };
+
+ const startRelayProbe = () => {
+ if (relayCancelled || settled) return;
+ if (headstartTimer !== undefined) {
+ window.clearTimeout(headstartTimer);
+ headstartTimer = undefined;
+ }
+ void probeRelay().then((result) => {
+ relayResult = result;
+ if (settled || relayCancelled) {
+ closeUnusedRelayTunnel(result);
+ return;
+ }
+ if (result.status === 'ok' || result.status === 'needs-login') {
+ finish(result);
+ return;
+ }
+ // Relay unreachable: direct is the only hope left.
+ if (directResult) finish(directResult);
+ });
+ };
+
+ void probeDirectChain().then((result) => {
+ directResult = result;
+ if (settled) return;
+ if (result.status === 'ok' || result.status === 'needs-login') {
+ relayCancelled = true;
+ if (headstartTimer !== undefined) window.clearTimeout(headstartTimer);
+ closeUnusedRelayTunnel(relayResult);
+ finish(result);
+ return;
+ }
+ // Every direct candidate is unreachable: hand over to relay immediately
+ // (skipping any remaining headstart) or settle on its finished verdict.
+ if (relayResult) {
+ finish(relayResult);
+ return;
+ }
+ startRelayProbe();
+ });
+
+ headstartTimer = window.setTimeout(startRelayProbe, RELAY_RACE_HEADSTART_MS);
+ });
};
// Switch the runtime to a chosen transport. `runtimeKey` is the STABLE device
@@ -770,10 +904,22 @@ const switchToTransport = (
options?: { runtimeKey?: string; grant?: string },
): void => {
if (transport.kind === 'relay') {
- switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey);
+ switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey, transport.tunnel);
} else {
switchRuntimeEndpoint({ apiBaseUrl: transport.url, clientToken: token, runtimeKey: options?.runtimeKey });
}
+ // Every live connection is an opportunity to learn the server's CURRENT LAN
+ // addresses (pairing-payload candidates go stale when DHCP reassigns the
+ // host's IP). Background-only: never blocks or repaints the connect flow.
+ scheduleCandidateRefresh();
+};
+
+// The display label of the instance cold-launch auto-connect will try (the
+// most-recently-used saved connection) — shown on the launch splash while the
+// connect races run. Null when there is nothing to auto-connect to.
+export const getAutoConnectTargetLabel = (): string | null => {
+ const candidate = readConnections()[0];
+ return candidate?.label?.trim() ? candidate.label : null;
};
// Cold-launch auto-connect: silently reconnect to the most-recently-used saved
@@ -864,6 +1010,9 @@ const pairingCandidatesToMobile = (candidates: PairingEndpointCandidate[]): Mobi
const establishLiveTransport = async (
candidates: MobileTransportCandidate[],
): Promise => {
+ // Same identity gate as probeConnectionCandidates: a redeem/login must not send
+ // its secret to a direct address that reports a different server identity.
+ const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null;
for (const candidate of candidates) {
if (candidate.kind === 'relay') {
const tunnel = createRelayTunnelClient(candidate.relay);
@@ -876,7 +1025,16 @@ const establishLiveTransport = async (
const url = normalizeConnectionUrl(candidate.url) || candidate.url;
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' });
logConnect('establish:direct:health', { ok: health?.ok === true, status: health?.status ?? null });
- if (health?.ok) return { kind: 'direct', url };
+ if (!health?.ok) continue;
+ if (expectedServerId) {
+ const payload = await health.json().catch(() => null);
+ const reported = payload && typeof payload === 'object' ? (payload as Record).serverId : null;
+ if (typeof reported === 'string' && reported && reported !== expectedServerId) {
+ logConnect('establish:server-id-mismatch', { url });
+ continue;
+ }
+ }
+ return { kind: 'direct', url };
}
return null;
};
@@ -963,7 +1121,14 @@ export const reprobeActiveConnection = async (): Promise => {
// 2. No better transport — is the current one still alive on its live channel?
if (currentIndex >= 0) {
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast: true });
- if (stillValid) return 'unchanged';
+ if (stillValid) {
+ // Still on the same transport (typically: woke up on the relay, old LAN
+ // candidate dead). Ask the server for its current LAN addresses in the
+ // background — if it moved, the refreshed candidates trigger one more
+ // re-probe and the hot-switch back to direct.
+ scheduleCandidateRefresh();
+ return 'unchanged';
+ }
}
// 3. Current transport is dead — fall through to lower-priority candidates.
@@ -977,6 +1142,115 @@ export const reprobeActiveConnection = async (): Promise => {
return 'unreachable';
};
+// ---------------------------------------------------------------------------
+// Candidate refresh: learn the server's CURRENT direct addresses over the live
+// (authenticated) runtime transport and update the saved candidate set, so a
+// device that paired under an old DHCP lease is not stuck on the relay forever.
+// ---------------------------------------------------------------------------
+
+// Let the post-switch bootstrap traffic settle before adding our own request.
+const CANDIDATE_REFRESH_DELAY_MS = 5_000;
+
+type CandidateRefreshResult = 'updated' | 'unchanged' | 'skipped';
+
+let candidateRefreshInFlight = false;
+
+// Fetch /api/client-auth/connection/candidates through the ACTIVE runtime
+// transport (direct or relay — runtimeFetch routes it) and merge the reported
+// LAN addresses into the active saved connection:
+// - fresh `lan` candidates REPLACE the previous http:// (LAN-class) direct
+// candidates — a LAN address the server no longer holds is dead weight that
+// slows every future re-probe;
+// - https:// (tunnel-class) direct candidates are preserved — the server does
+// not know its own public tunnel hostnames;
+// - the relay candidate is preserved as the last-resort transport.
+// Only runs for relay-paired connections: their token/runtime key derives from
+// the stable relay identity, so rewriting direct URLs cannot orphan the stored
+// token. The response must echo the connection's serverId or it is ignored.
+export const refreshActiveConnectionCandidates = async (): Promise => {
+ if (candidateRefreshInFlight) return 'skipped';
+ const active = findActiveConnection();
+ if (!active) {
+ logConnect('candidates:refresh-skip', { reason: 'no-active-connection' });
+ return 'skipped';
+ }
+ const relay = relayCandidateOf(active);
+ if (!relay) {
+ logConnect('candidates:refresh-skip', { reason: 'no-relay-candidate' });
+ return 'skipped';
+ }
+ candidateRefreshInFlight = true;
+ try {
+ const response = await raceWithTimeout(
+ RELAY_CONNECT_TIMEOUT_MS,
+ runtimeFetch('/api/client-auth/connection/candidates').then((r): Response | null => r).catch(() => null),
+ );
+ if (!response?.ok) {
+ logConnect('candidates:refresh-skip', { reason: 'fetch-failed', status: response?.status ?? null });
+ return 'skipped';
+ }
+ const payload = await response.json().catch(() => null) as { serverId?: unknown; candidates?: unknown } | null;
+ // Identity gate: the refresh must come from the server this device paired
+ // with. Old servers (no serverId) are skipped rather than trusted blindly.
+ if (!payload || payload.serverId !== relay.serverId) {
+ logConnect('candidates:refresh-skip', { reason: 'server-id-mismatch' });
+ return 'skipped';
+ }
+ const reported = Array.isArray(payload.candidates) ? payload.candidates : [];
+ const lanUrls: string[] = [];
+ for (const entry of reported) {
+ if (!entry || typeof entry !== 'object') continue;
+ const record = entry as Record;
+ if (record.type !== 'lan' || typeof record.url !== 'string') continue;
+ try {
+ const url = normalizeConnectionUrl(record.url);
+ if (url && !lanUrls.includes(url)) lanUrls.push(url);
+ } catch {
+ // invalid URL → drop
+ }
+ }
+ // No LAN reported (loopback-only bind or interface-scan failure): keep the
+ // existing candidates — deleting them on a possibly-transient empty answer
+ // would be silent data loss; a stale entry only costs one fast probe.
+ if (lanUrls.length === 0) {
+ logConnect('candidates:refresh-skip', { reason: 'no-lan-reported' });
+ return 'skipped';
+ }
+ const preservedHttps = directCandidates(active).filter((candidate) => candidate.url.startsWith('https://'));
+ const next: MobileTransportCandidate[] = [
+ ...lanUrls.map((url): MobileTransportCandidate => ({ kind: 'direct', url })),
+ ...preservedHttps,
+ { kind: 'relay', relay },
+ ];
+ const unchanged = JSON.stringify(active.candidates.map(serializeCandidate)) === JSON.stringify(next.map(serializeCandidate));
+ if (unchanged) return 'unchanged';
+ logConnect('candidates:refreshed', { lanCount: lanUrls.length });
+ await upsertMobileConnection({ id: active.id, label: active.label, candidates: next });
+ return 'updated';
+ } finally {
+ candidateRefreshInFlight = false;
+ }
+};
+
+// Background candidate refresh + opportunistic hot-switch. Fire-and-forget by
+// design: no UI state, no rerenders — the only visible effect is the runtime
+// quietly switching relay → direct when a fresh LAN address turns out reachable
+// (reprobeActiveConnection re-reads storage and applies its usual identity-gated
+// probe + stable-runtime-key switch). Converges: a re-entered refresh reports
+// 'unchanged'/'skipped', which never triggers another re-probe.
+const scheduleCandidateRefresh = (): void => {
+ if (typeof window === 'undefined') return;
+ window.setTimeout(() => {
+ void (async () => {
+ const result = await refreshActiveConnectionCandidates().catch((): CandidateRefreshResult => 'skipped');
+ logConnect('candidates:refresh-result', { result });
+ if (result === 'updated' && isRelayModeActive()) {
+ await reprobeActiveConnection().catch(() => null);
+ }
+ })();
+ }, CANDIDATE_REFRESH_DELAY_MS);
+};
+
// ---------------------------------------------------------------------------
// Shared connection controller
// ---------------------------------------------------------------------------
@@ -1107,8 +1381,10 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
setError(null);
beginBusy('pairing');
const deviceCandidates = pairingCandidatesToMobile(payload.candidates);
- // A chosen relay transport owns an open tunnel; always close it.
+ // A chosen relay transport owns an open tunnel; close it unless the switch
+ // adopted it as the runtime tunnel.
let chosen: LiveTransport | null = null;
+ let adopted = false;
try {
// 1. Find the first reachable transport across all candidates.
chosen = await establishLiveTransport(deviceCandidates);
@@ -1165,17 +1441,20 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
}
}
persistMetadata({ label, candidates: deviceCandidates, clientToken: issuedToken });
+ // A relay transport hands its live redeem tunnel to the runtime (adopted
+ // inside switchToTransport) — closing it here would tear down the runtime.
switchToTransport(
- chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay } : { kind: 'direct', url: chosen.url },
+ chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url },
issuedToken,
{ runtimeKey: secureTokenKeyOf({ candidates: deviceCandidates }) },
);
+ adopted = chosen.kind === 'relay';
onConnected();
} catch (error) {
console.warn('[mobile-connect] pairing threw', error);
setError(t('mobile.connect.error.authRequired'));
} finally {
- if (chosen?.kind === 'relay') chosen.tunnel.close();
+ if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
endBusy('pairing');
}
}, [beginBusy, endBusy, onConnected, persistMetadata, t]);
@@ -1185,8 +1464,10 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
setError(null);
beginBusy('password');
const { id, label, candidates } = pendingConnection;
- // A chosen relay transport owns an open tunnel; always close it.
+ // A chosen relay transport owns an open tunnel; close it unless the switch
+ // adopted it as the runtime tunnel.
let chosen: LiveTransport | null = null;
+ let adopted = false;
try {
// Log in over whichever transport is reachable. Relay login rides the
// tunnel; cookies never cross it, so an issued bearer token is mandatory
@@ -1238,17 +1519,20 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
}
persistMetadata({ id, label, candidates, clientToken: issuedToken });
setPendingConnection(null);
+ // A relay transport hands its live login tunnel to the runtime (adopted
+ // inside switchToTransport) — closing it here would tear down the runtime.
switchToTransport(
- chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay } : { kind: 'direct', url: chosen.url },
+ chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url },
issuedToken,
{ runtimeKey: secureTokenKeyOf({ candidates }) },
);
+ adopted = chosen.kind === 'relay';
onConnected();
} catch (error) {
console.warn('[mobile-connect] password threw', error);
setError(t('mobile.connect.error.passwordFailed'));
} finally {
- if (chosen?.kind === 'relay') chosen.tunnel.close();
+ if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
endBusy('password');
}
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
@@ -1260,16 +1544,44 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => {
setError(null);
- const candidates = buildCandidatesFromInput(input);
+ let candidates = buildCandidatesFromInput(input);
+ const existing = input.id ? connectionsRef.current.find((connection) => connection.id === input.id) ?? null : null;
+ if (existing) {
+ // EDIT must never silently drop transports the form does not show. The
+ // form carries one URL, but a paired device also has a relay candidate
+ // (whose identity derives the Keychain token key) and possibly https
+ // tunnel candidates. Same merge policy as the background candidate
+ // refresh: the typed URL replaces the http:// (LAN-class) directs;
+ // https:// directs and the relay candidate are preserved. Dropping the
+ // relay here used to change the token key and orphan the stored token.
+ const inputDirects = candidates.filter((c): c is Extract => c.kind === 'direct');
+ const preservedHttps = directCandidates(existing).filter(
+ (c) => c.url.startsWith('https://') && !inputDirects.some((n) => isSameConnectionUrl(n.url, c.url)),
+ );
+ const relay = relayCandidateOf(existing);
+ candidates = [...inputDirects, ...preservedHttps, ...(relay ? [{ kind: 'relay' as const, relay }] : [])];
+ }
if (candidates.length === 0) {
setError(t('mobile.connect.error.urlRequired'));
return null;
}
const clientToken = input.clientToken?.trim() || undefined;
const label = input.label?.trim() || getConnectionLabel(connectionDisplayUrl({ candidates }));
- // Awaited token write so "Save" truly persisted the secret before returning.
- if (isCapacitorApp() && clientToken) {
- await writeSecureToken(secureTokenKeyOf({ candidates }), clientToken);
+ // Awaited token writes so "Save" truly persisted the secret before returning.
+ if (isCapacitorApp()) {
+ const nextKey = secureTokenKeyOf({ candidates });
+ if (clientToken) {
+ await writeSecureToken(nextKey, clientToken);
+ } else if (existing?.hasToken) {
+ // No new token typed but the edit changed the token key (e.g. a
+ // direct-only instance got a new URL): move the stored token to the
+ // new key instead of leaving it stranded under the old one.
+ const previousKey = secureTokenKeyOf(existing);
+ if (previousKey && nextKey && previousKey !== nextKey) {
+ const storedToken = await readSecureToken(previousKey);
+ if (storedToken) await writeSecureToken(nextKey, storedToken);
+ }
+ }
}
const next = persistMetadata({ id: input.id, label, candidates, clientToken });
return next.find((connection) => candidateSetsMatch(connection.candidates, candidates)) ?? null;
diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts
index b4150cd214..a6a8cb00e7 100644
--- a/packages/ui/src/apps/runtimeEndpointReset.ts
+++ b/packages/ui/src/apps/runtimeEndpointReset.ts
@@ -6,6 +6,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
+import { usePermissionStore } from '@/stores/permissionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming';
@@ -43,6 +44,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
// Cross-project session list (mobile sessions sheet & co) belongs to the
// previous instance — drop it so stale sessions can't linger after a switch.
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
+ usePermissionStore.getState().reset();
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx
index 558e77c812..826b30f2a1 100644
--- a/packages/ui/src/components/auth/SessionAuthGate.tsx
+++ b/packages/ui/src/components/auth/SessionAuthGate.tsx
@@ -27,6 +27,16 @@ import {
} from '@/lib/passkeys';
const STATUS_CHECK_ENDPOINT = '/auth/session';
+// Transient-failure auto-retry for the initial session check. Over the relay the
+// very first /auth/session can race the tunnel's initial WebSocket attempt (a
+// failed attempt rejects requests queued on the channel even though the tunnel
+// immediately reconnects), and on a lossy link the first request can simply drop.
+// A single-shot check pins the gate on the error screen for a self-healing
+// condition, so network errors and non-auth server errors (5xx during startup)
+// retry a bounded number of times before surfacing the error UI. Definitive auth
+// answers (200/401/429) are never retried.
+const TRANSIENT_RETRY_MAX_ATTEMPTS = 4;
+const TRANSIENT_RETRY_BASE_DELAY_MS = 1_500;
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
@@ -373,6 +383,40 @@ export const SessionAuthGate: React.FC = ({
};
}, [skipAuth]);
+ // Bounded retry scheduling for transient session-check failures. Lives in refs
+ // so retries survive re-renders; the timer is cleared on unmount, endpoint
+ // switch, and any definitive server answer.
+ const transientRetryAttemptRef = React.useRef(0);
+ const transientRetryTimerRef = React.useRef(null);
+ const checkStatusRef = React.useRef<(() => Promise) | null>(null);
+
+ const clearTransientRetry = React.useCallback(() => {
+ if (transientRetryTimerRef.current !== null) {
+ window.clearTimeout(transientRetryTimerRef.current);
+ transientRetryTimerRef.current = null;
+ }
+ }, []);
+
+ const resetTransientRetry = React.useCallback(() => {
+ transientRetryAttemptRef.current = 0;
+ clearTransientRetry();
+ }, [clearTransientRetry]);
+
+ // Returns true when another attempt was scheduled (caller keeps the pending
+ // UI); false when the retry budget is exhausted (caller shows the error UI).
+ const scheduleTransientRetry = React.useCallback((): boolean => {
+ if (transientRetryAttemptRef.current >= TRANSIENT_RETRY_MAX_ATTEMPTS) return false;
+ transientRetryAttemptRef.current += 1;
+ clearTransientRetry();
+ transientRetryTimerRef.current = window.setTimeout(() => {
+ transientRetryTimerRef.current = null;
+ void checkStatusRef.current?.();
+ }, TRANSIENT_RETRY_BASE_DELAY_MS * transientRetryAttemptRef.current);
+ return true;
+ }, [clearTransientRetry]);
+
+ React.useEffect(() => clearTransientRetry, [clearTransientRetry]);
+
const checkStatus = React.useCallback(async () => {
if (skipAuth) {
setState('authenticated');
@@ -386,8 +430,9 @@ export const SessionAuthGate: React.FC = ({
refreshPasskeyStatus(),
]);
const responseText = await response.text();
-
+
if (response.ok) {
+ resetTransientRetry();
setState('authenticated');
setIsTunnelLocked(false);
setErrorMessage('');
@@ -401,6 +446,7 @@ export const SessionAuthGate: React.FC = ({
} catch {
data = {};
}
+ resetTransientRetry();
setIsTunnelLocked(data.tunnelLocked === true);
setPasskeyStatus(latestPasskeyStatus);
setState('locked');
@@ -414,11 +460,15 @@ export const SessionAuthGate: React.FC = ({
} catch {
data = {};
}
+ resetTransientRetry();
setRetryAfter(data.retryAfter);
setIsTunnelLocked(false);
setState('rate-limited');
return;
}
+ // Non-auth server error (e.g. 502/503 while the backend is still coming
+ // up) — transient; keep the pending UI and retry before surfacing.
+ if (scheduleTransientRetry()) return;
setState('error');
setIsTunnelLocked(false);
} catch (error) {
@@ -429,10 +479,17 @@ export const SessionAuthGate: React.FC = ({
setIsTunnelLocked(false);
return;
}
+ // Network-level failure — over the relay this is typically the initial
+ // tunnel attempt racing this request; it self-heals within seconds.
+ if (scheduleTransientRetry()) return;
setState('error');
setIsTunnelLocked(false);
}
- }, [refreshPasskeyStatus, skipAuth]);
+ }, [refreshPasskeyStatus, resetTransientRetry, scheduleTransientRetry, skipAuth]);
+
+ React.useEffect(() => {
+ checkStatusRef.current = checkStatus;
+ }, [checkStatus]);
React.useEffect(() => {
if (skipAuth) {
@@ -451,10 +508,11 @@ export const SessionAuthGate: React.FC = ({
setErrorMessage('');
setRetryAfter(undefined);
setIsTunnelLocked(false);
+ resetTransientRetry();
setState('pending');
void checkStatus();
});
- }, [checkStatus, skipAuth]);
+ }, [checkStatus, resetTransientRetry, skipAuth]);
React.useEffect(() => {
if (!skipAuth && state === 'locked') {
@@ -719,7 +777,7 @@ export const SessionAuthGate: React.FC = ({
if (state === 'error') {
return (
- void checkStatus()} errorType="network">
+ { resetTransientRetry(); void checkStatus(); }} errorType="network">
{showHostSwitcher && (
+ ) : null}
+ {/* Only failure states carry a reason worth reading; outcomes
+ like "verified by audit" are noise next to the status dot. */}
+ {goal.statusReason && (goal.status === 'blocked' || goal.status === 'budgetLimited') ? (
+