Files
cline/apps/examples/desktop-app
BeeandSaoud Rizwan 4d28d82efa feat(cli): handle outdated hub sessions with drain and replace flow (#13727)
* feat(cli): handle outdated hub sessions with drain and replace flow

Add logic to detect when the CLI is newer than the running Hub and provide
users with options to either keep the older Hub running (to avoid
interrupting active sessions from other clients) or force-replace it.

Implement `describeOutdatedHubSessions` helper to show quantified session
activity in the dialog, and add `HubOutdatedContent` UI component with
detailed messaging for the `build_mismatch` case. The `unsupported_protocol`
case remains a modal requiring update, while the softer mismatch now uses
a toast with enter-to-replace or escape-to-keep choices.

Includes tests for draining and replacing an older busy hub when forced.

* fix(hub): gate desktop hub_upgrade behind trusted connection and make drain-first a hard guarantee

Address review: an originless local WebSocket client could invoke the
forceful hub_upgrade command, and a failed drain request still allowed a
forced retirement, so work started during the wait window could be killed.

- hub_upgrade now requires the same canApproveTools per-connection gate as
  the tool-approval commands.
- upgradeManagedHub skips the idle-wait window when the drain was not
  established (an undrained hub keeps admitting work, so waiting only
  widens the blast radius) and refuses to replace a busy hub that did not
  accept the drain, force or not. An idle hub is still replaced so
  pre-drain-endpoint hubs (404) remain upgradable.

* fix(hub): treat failed activity readings as unknown, not idle, during hub upgrade

A transient session.list failure inside the drain wait window previously
read as an idle hub, which could end the grace window early and authorize
retirement while turns were still finishing.

- Failed readings never end the wait window early, never overwrite the
  last real observation, and never authorize a non-forced retirement.
- Without force, a hub whose activity was never confirmed is handed back
  un-drained (still_busy) instead of retired; an undrained hub is now
  replaced only when positively observed idle.
- With force and an accepted drain, an unanswerable hub is still replaced:
  the user already consented to interrupting its sessions.

* fix(hub): never retire an undrained hub on an idle snapshot

An older hub that rejects the drain has no admission barrier, so a single
idle reading cannot authorize retirement: a session admitted right after
the snapshot would die in a retire the consent prompt never covered.

upgradeManagedHub now retires a hub only under an accepted drain. The
undrained-idle case is delegated to the locked ensure path, which
re-checks activity immediately before its own retire ladder and attaches
(deferring the swap) when new work arrived in the meantime; the upgrade
then reports still_busy instead of replaced, and the desktop/TUI surfaces
tell the user to retry.

* fix(hub): require an accepted drain unconditionally before any upgrade retirement

Review follow-up: the undrained-idle delegation still reached
retireDiscoveredHub, whose own drain attempt is best-effort, so a session
admitted after the idle re-check could die in the shutdown.

upgradeManagedHub now fails fast when the hub does not accept the drain -
no wait window, no idle exception, no delegation. The drain is the
admission barrier that keeps every subsequent reading true through the
retire; a hub too old or wedged to accept it is left to the automatic
ensure path, which replaces it once idle at the next client startup, and
the error says so.

* fix(hub): establish the drain barrier before the automatic idle check

Review follow-up: the automatic incompatible-hub path read session
activity first and drained only inside the retire ladder, so a session
admitted between the idle snapshot and the shutdown could be terminated.

retireIncompatibleHub now requests the drain before the busy check: with
the drain accepted, the idle reading stays true through the retire. A
deferred (busy) hub, and one whose retirement fails or is skipped by the
circuit breaker, gets the drain lifted so it never sits alive-but-refusing
work. Hubs that do not accept the drain (pre-/drain builds answer 404)
keep the historical best-effort snapshot rather than being stranded
forever.

* polish(hub): tighten the outdated-hub dialog copy

Two short sentences instead of four long ones, spell out what Quit Cline
does (closes the app, leaves the Hub running), and rename the action to
Update Now in both the desktop dialog and the TUI variant.

* fix(cli): show the keep-Hub reminder toast when the outdated-hub dialog is dismissed (#13754)

dialog.choice() resolves undefined on Esc rather than rejecting, so the
reminder toast in .catch() never ran. Move it to the falsy branch of
.then(), matching the unsupported_protocol handler.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-01 20:40:40 -07:00
..
2026-06-03 01:49:57 +02:00
2026-08-31 14:23:28 -07:00
2026-06-03 01:49:57 +02:00
2026-08-31 14:23:28 -07:00

Desktop App Example

Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspecting Cline chat sessions.

Dev Commands

From apps/examples/desktop-app/:

  • bun run dev:headless - Next.js UI (http://localhost:3125) and sidecar backend with a fresh shared approval credential
  • bun run dev:web - Next.js UI only (approval-gated tools require dev:headless or the native app)
  • bun run dev:sidecar - sidecar backend only (approval-gated tools require dev:headless or the native app)
  • bun run dev - Tauri desktop dev
  • bun run build - build web assets
  • bun run build:sidecar - build the Bun sidecar bundle
  • bun run build:sidecar:bin - compile the Bun sidecar into a local binary
  • bun run build:binary - build desktop binary
  • bun run package:desktop - package the current OS desktop app into dist/desktop/
  • bun run typecheck - TypeScript check

Customizing the macOS Install Window

The drag-to-Applications window is configured by bundle.macOS.dmg in src-tauri/tauri.conf.json. Its artwork comes from the PNG sources in src-tauri/dmg/; the background.gen.tiff Finder actually renders is a gitignored build artifact regenerated from them on every build.

  1. The current source artwork is 640x400. Export background.png at 1x and background@2x.png at 2x.
  2. Currently the app icons are centered at (140, 200) and the Applications folder centered at (500, 200). If updating artwork, update appPosition and applicationFolderPosition to reposition the app icons.
  3. Build with bun run build:binary. Before compiling, the build validates both PNG dimensions, combines them with tiffutil into the Retina-aware src-tauri/dmg/background.gen.tiff, and verifies the TIFF contains the expected 1x and 2x representations. Run bun run dmg:background to do just that step, e.g. to sanity-check new artwork without a full build. The DMG is written beneath src-tauri/target/release/bundle/dmg/.

Run bun run test:dmg-background for the cross-platform checks covering the committed PNG dimensions and TIFF validation logic.

The configured 640x432 Finder window is intentionally 32 points taller than the 640x400 background. That extra height matches the Finder chrome in the currently verified packaged layout; re-check it after material macOS or Finder changes. The project deliberately uses a multi-resolution TIFF even though Tauri's documented background formats are PNG, JPG, and GIF: Finder renders both the 1x and 2x representations from a single background file. Re-check the packaged DMG after upgrading Tauri in case its background validation changes.

Login Shell PATH Resolution

Apps launched from Finder/the Dock inherit launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), not the one your shell profiles build, so agent-run commands would miss Homebrew-installed tools like gh even though they work fine from a terminal. At startup the sidecar asks the user's login shell — read from the account database via getpwuid, falling back to $SHELL — for its PATH and merges it into process.env.PATH, which every agent-spawned child (run_commands, MCP servers) inherits. Only PATH is imported, deliberately; other login-environment variables (SSH_AUTH_SOCK, API keys, JAVA_HOME-style tool roots) are not pulled in. Set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to disable. Implementation and details: sidecar/shell-path.ts.

Web Visual System

The framework-neutral color, typography, radius, and navigation contract lives in the internal @cline/ui workspace package. Other Cline web surfaces can take only its tokens or opt into the Tailwind adapter and shared base styles without depending on the desktop runtime. See webview/styles/README.md for the desktop integration notes.

Releases & Auto-Updates

Releases are built, signed, notarized, and published by the desktop-publish GitHub workflow as a single universal macOS DMG — one download that runs natively on both Apple Silicon and Intel (macOS picks the matching slice at launch, so users never choose an architecture). The step-by-step flow (version bumps, changelog, tag, repo secrets) lives in the publish-desktop skill (.cline/skills/publish-desktop/SKILL.md).

Installed apps auto-update via the Tauri updater: they poll the rolling desktop-latest release's latest.json on launch and every 2 hours, install updates in the background, and prompt for a restart. Two things must never be lost: the desktop-latest release/tag (its feed URL is baked into shipped apps) and the updater private key (TAURI_SIGNING_PRIVATE_KEY — without it, shipped apps can't verify new updates).

There is also a beta channel ("Cline Beta", a separate app that installs side by side with stable) cut from the desktop-experimental branch and served by the rolling desktop-beta release — the same never-delete rule applies to it. The experimental-branch process and beta release flow live in EXPERIMENTAL.md.

Shareable Desktop Packages (manual fallback)

Tauri desktop bundles are OS-specific, so build each package on the target OS:

  • macOS: bun run package:desktop:mac
  • Windows: bun run package:desktop:windows
  • Linux: bun run package:desktop:linux

The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.

Set either APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY, plus one notarization credential set before packaging macOS:

  • APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID
  • APPLE_API_KEY or APPLE_API_KEY_PATH, APPLE_API_KEY_ID, APPLE_API_ISSUER

For local-only macOS testing, use bun run package:desktop:mac --allow-unsigned-mac. That ad-hoc signs the .app and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.

macOS signing & notarization, step by step

One-time keychain setup:

  1. Get the Developer ID Application identity from your team admin. A .cer alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a .p12 and import it: security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security
  2. If security find-identity -v -p codesigning still reports 0 valid identities, the Apple intermediate CA is missing. Install it: curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db
  3. Re-run security find-identity -v -p codesigning — it should now list Developer ID Application: <Team Name> (<TEAMID>). That exact quoted string is your APPLE_SIGNING_IDENTITY.
  4. Get an App Store Connect API key from the admin: the AuthKey_<KEYID>.p8 file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.

Per-build:

export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
export APPLE_API_KEY="<KEYID>"           # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
export APPLE_API_ISSUER="<issuer UUID>"
bun run package:desktop:mac

The first signing run pops a keychain dialog — enter your macOS login password and click Always Allow. Notarization uploads the app to Apple's automated malware scan (typically 210 minutes) and staples the ticket. Artifacts land in dist/desktop/; share the .dmg. The DMG name takes its version from src-tauri/tauri.conf.json, the zip name from package.json — bump both.

Do not remove src-tauri/entitlements.plist or the bundle.macOS.entitlements reference in tauri.conf.json: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (SharedArrayBuffer is not defined, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.

Runtime Overview

Startup flow:

  1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
  2. The desktop backend starts the Bun sidecar, which discovers or starts the canonical shared Cline Hub and exposes one websocket transport (/transport) for desktop commands, queries, and pushed events.
  3. The React app uses lib/desktop-client.ts and no longer imports @tauri-apps/api/core directly in feature code.
  4. Tool approval updates are pushed from the backend instead of polled from the UI.
  5. Session process context resolves workspaceRoot from git root and uses that same path as default cwd for chat runtime and git operations unless explicitly overridden.

Desktop transport envelope:

  • Request: { "type": "command", "id": string, "command": string, "args"?: object }
  • Response: { "type": "response", "id": string, "ok": boolean, "result"?: unknown, "error"?: string }
  • Event: { "type": "event", "event": { "name": string, "payload": unknown } }

Settings: Routine

  • The Settings sidebar includes a Routine view for hub-backed automations.
  • Routine lists all RPC schedules and shows status (enabled, nextRunAt, active execution).
  • From the UI you can open a create form and add, pause/resume, trigger-now, and delete schedules.
  • The view is wired to the same scheduler APIs used by cline schedule through Tauri commands and scripts/routine-schedules.ts.

Key Files

Data + Storage

  • Session artifacts are written under ~/.cline/data/sessions/<sessionId>/ (or CLINE_SESSION_DATA_DIR).
  • Canonical replay/export artifact: <sessionId>.messages.json.
  • <sessionId>.messages.json is expected to contain ordered messages plus assistant modelInfo and metrics (including cache token fields when provided by the model runtime).
  • <sessionId>.hooks.jsonl is observability/debug telemetry and should not be required for normal history replay/export flows.
  • Full v1 schema for the persisted messages file, including failure/retry semantics and golden fixtures, is documented in packages/core/docs/messages-contract-v1.md.

Sidecar observability

The desktop sidecar sends SDK telemetry through the same configured OpenTelemetry pipeline used by the CLI and writes structured runtime logs to ~/.cline/data/logs/code.log by default. Telemetry continues to honor the global opt-out setting exposed in the desktop settings UI. The sidecar truncates stale logs and rotates the active file before it exceeds 50 MiB.

Logging can be configured with the same environment variables as the CLI:

  • CLINE_LOG_ENABLED=0 disables file logging.
  • CLINE_LOG_LEVEL sets the Pino level (for example, debug or warn).
  • CLINE_LOG_PATH overrides the log destination.
  • CLINE_LOG_NAME overrides the logger name.

In a development webview, sidecar voice-input diagnostics are also streamed to the webview console as [desktop:voice-input] entries. Production builds can enable the same console stream with NEXT_PUBLIC_CLINE_DEBUG_LOGS=1 at build time, or at runtime from DevTools with localStorage.setItem("cline.debugLogs", "1") followed by a reload. Diagnostic events include the selected provider/model and sanitized endpoint, but never credentials, request headers, recorded audio, or transcript contents.

Troubleshooting

  • If live updates stall, verify the desktop backend websocket is connected and chat_event messages are arriving.
  • Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
  • Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
  • If a turn completes with finishReason=error before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
  • If package changes are not reflected, rebuild SDK packages (bun run build:sdk). The next desktop or CLI Hub connection will reuse a compatible running Hub or replace an incompatible one through the shared discovery path.
  • Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
  • Speech input requires an enabled provider whose models.dev metadata identifies a dedicated audio-to-text model, or the built-in ElevenLabs provider with its Scribe v2 model. Choose the voice input provider and model explicitly under Settings → Models → Voice input. That selection is stored separately from the chat model as modes.voiceInput in ~/.cline/data/settings/providers.json; provider credentials remain in their existing provider entry and never enter the webview. ElevenLabs uses its native /v1/speech-to-text API. Text-to-speech models with output: ["audio"] are not used for microphone transcription.
  • Streaming transcription models, such as Vercel AI Gateway's openai/gpt-realtime-whisper, update the composer while the user speaks. The sidecar mints a short-lived transcription token; the long-lived gateway credential is never sent to the webview. Batch models such as openai/whisper-1 continue to transcribe after recording stops.