Files
openchamber/AGENTS.md
T
Bohdan Triapitsyn 61a4a23add feat: native iOS & Android mobile apps (Capacitor) (#1954)
* feat(mobile): add Capacitor native shell

* docs: add serve-sim workflow guidance

* docs(mobile): add implementation handoff

* chore(mobile): clean up generated defaults

* feat(mobile): add connection onboarding

* feat(mobile): manage saved instances

* feat(mobile): refine connection management UI

* chore(mobile): upgrade Capacitor 8

* fix(mobile): reliable saved-instance auth with secure token storage

- store client tokens in the OS secure store (iOS Keychain / Android Keystore)
  per instance URL via direct native plugin calls; keep only token-less metadata
  in localStorage. Bound every secure call so a stalled bridge can't hang unlock.
- bypass the secure-storage JS wrapper's lazy platform load (which stalled in the
  webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly.
- harden the shared connect/unlock controller (health + session + progressive
  password) and drop the heavy pre-connect hydration that stalled no-token hosts.
- await token persistence before switching runtime endpoints (no fire-and-forget).
- sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8.

* fix(mobile): keep UI stable across connection churn (no transport hardcoding)

The "reload every ~10s" was a UX bug, not a transport one:
- MobileSurfaceShell received a fresh inline onClose each parent render, so any
  re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first
  element — stealing focus from the active input and collapsing the keyboard
  mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on
  `open`. Fixes all sheets (Instances/Files/Changes/Settings).
- Gate the mobile shell on connectionPhase, not the live isConnected flag, so a
  transient reconnect keeps MobileShell mounted instead of flashing the loader.
- Instances form: populate fields imperatively on edit/cancel/save instead of via
  an effect keyed on the derived connection, so list churn can't wipe input.

Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so
WS-only Quick Tunnels and SSE-capable proxies both keep working.

* feat(mobile): add native QR pairing-code scanner

Wire the connection onboarding + Instances scan buttons to a real native
scanner via @capacitor-mlkit/barcode-scanning, which registers as the
BarcodeScanner plugin the existing mobileQrScan helper already resolves at
runtime. Add NSCameraUsageDescription and bump the iOS deployment target to
15.5 (GoogleMLKit 8 requirement).

* fix(cli): repair connect-url host resolution

Define the missing isWildcardBindHost helper that connect-url called but was
never declared, which crashed any link generation that reached host
resolution. Also treat a full http(s) --host value as a public server URL so
'--host https://example.com' produces a correct link instead of
'http://https://example.com:port'.

* fix(mobile): make input follow the keyboard across all surfaces

Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the
layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which
fires at the start of the iOS keyboard animation. A transition tuned to the
native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes
the layout rise together with the keyboard instead of snapping into place after
the built-in 'native' resize finished (~1.5s lag).

The inset is consumed by every surface that can hold a focused input:
- chat shell shrinks its height;
- portal sheets/overlays raise their bottom edge;
- the full-screen connect/login view caps its height so it actually scrolls
  (and is now generally scrollable for long saved-connection lists).

* feat(mobile): rounder chat composer + native bottom safe area

Round the mobile chat composer corners a touch more (1rem), and reserve a small
app-level bottom safe area for the native shell via the --oc-app-bottom-safe
token so controls clear the phone's rounded hardware corners. The reservation
folds into the keyboard inset (no gap above the keyboard), and the composer's
own bottom padding tightens while the keyboard is open.

* fix(mobile): remove iOS 26 dark status-bar band; polish composer

The dark band behind the status bar in system Dark Mode was iOS 26's automatic
scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the
status bar — appearance-coloured, so it tracked the system theme regardless of
the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the
WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed
web background shows under the overlaid status bar.

Also: re-assert the status-bar overlay on resume, paint the document canvas with
the theme background in the native shell, round the composer corners to 1.5rem,
and enlarge the app-level bottom safe area so controls clear the rounded corners.

* feat(mobile): logo splash until first paint is final (no FOUT / layout shift)

Cold start flashed the fallback font and then reflowed once the real font and
persisted appearance prefs landed, and text jumped a frame after mount because the
mobile typography classes were applied from a hook effect. Fix it on three fronts:

- apply device classes (device-mobile / mobile-pointer) synchronously in
  renderMobileApp before the first React paint, so mobile --text-* sizes are in
  effect from the start;
- hold a logo splash (useFontsReady) until the UI web font has loaded;
- gate that splash on appBootReady too, resolved once async appearance/typography
  preferences are applied, plus a double rAF so styles commit before reveal.

All under a 2.5s safety timeout so a slow/offline CDN can't block startup.

* feat(mobile): native local notifications; APNs implemented but frozen

The native app now delivers agent ready/error/question/permission events as iOS
(and Android) Local Notifications: a native notifications API backed by
@capacitor/local-notifications replaces the Web Notifications API (which doesn't
display in a WKWebView), driven by the notification SSE stream now subscribed in
the mobile app. Tapping a notification opens its session. Also fix the settings
toggle, which treated the Capacitor app as a browser and gated 'Enable
Notifications' on the absent Web Notification permission, leaving it un-toggleable.

Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT
server runtime, token routes, client registration, iOS native config) but kept
dormant: config-gated so it never fires, client registration not wired, and the
aps-environment entitlement / background mode removed so the app builds with no
Apple push setup. It will be reused once OpenChamber ships its own encrypted
relay so users don't each configure APNs. See notifications/APNS.md.

WKWebView can't use web push (unlike an installed PWA), so true
background-when-suspended delivery on native requires APNs via that relay.

* feat(mobile): APNs relay-mode background push

Deliver native iOS background push through the central relay: the server posts
device tokens + generic, model-based text to api.openchamber.dev/v1/push/send
(default), which holds the single APNs key and signs+sends; dead tokens (410)
are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays
as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload
is generic only (model + scenario) so no session content crosses the relay.

Re-enable the client token registration (useNativePushRegistration) and the
aps-environment entitlement (alert pushes need no background mode). Wired into
the same fanout as web push; focus-suppressed and only when tokens exist.

* fix(mobile): APNs-only native notifications, generic templates, no foreground

Make APNs the single notification channel for the native app and fix delivery:

- Remove local notifications entirely (the @capacitor/local-notifications plugin
  and the SSE-driven path). A WKWebView can't tell foreground from background
  (document.hasFocus() is unreliable), so local notifications leaked while the app
  was open; the in-app dispatch is no-op'd on native.
- Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden'
  before iOS suspends it, which dropped background push. Instead always send and let
  iOS suppress the foreground banner (PushNotifications presentationOptions: []).
- Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger
  before any push was sent.
- Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs
  your input' / 'needs permission' / 'hit an error') + the session name, no model or
  message content.
- Hide the focus toggle, templates, and test button in mobile notification settings.

* feat(push): sign relay requests + bind tokens per server

Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings,
like the VAPID keys) and uses it to:
- bind every newly-seen device token to the server on the relay
  (POST /v1/push/register-token, signed), and
- sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title).

The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and
only delivers to tokens bound to that server. Result: a leaked device token alone can no
longer be used to push to a device — the sender also needs the server's private key. Stays
zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer.

* docs(push): describe relay data-confidentiality model

Document that the push payload is not application-encrypted (TLS-in-transit only), what the
relay and Apple can see (generic scenario title + session name, plus token/sessionId), that
the signature is authentication rather than encryption, and what an end-to-end encrypted
payload would require.

* fix: invalid skill description

* feat(push): app-icon badge for native notifications

Send an absolute aps.badge with each native push = the count of distinct
collapse-ids (tag) pushed since the app was last foregrounded, mirroring the
lock-screen banner stack. Cleared server-side on user engagement (session view,
message-sent, visibility beacon) and on-device via sceneDidBecomeActive.

* feat(mobile): auto-connect last instance on launch + notification deep-links

Cold launch silently reconnects to the most-recent saved instance (when reachable
and a token is saved), holding the splash instead of flashing the connect screen;
falls back to the connect screen when there's no saved instance, it's unreachable,
or it needs a re-login. Notification-tap deep-links are now captured unconditionally
(even before connect / on cold launch) and applied once the app is ready, so a tap
opens the target session instead of being lost on the login screen.

* fix(mobile): resolve theme background before first paint on cold launch

The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold
launch flashed the WebView's default light canvas, then the baked
design-system default (.dark { --background: #151313 }) via body.bg-background,
before React's theme system injected the real theme vars. Add a blocking script
that resolves dark/light from the persisted theme + system preference and sets
--background (plus color-scheme and the element background) inline on the root,
so the very first paint matches the resolved theme. Falls back to the default
flexoki backgrounds when no theme has been persisted yet.

* feat(mobile): openchamber:// deep-link foundation + arm64 simulator build

Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent)
and a single native navigation layer (deepLinkNavigation.ts) that handles both
the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities,
external links) and notification taps, normalising each into an intent. Session
and new-session resolve against the store; shell surfaces (sessions/settings/
views/changes) register handlers. Cold-launch intents stash until the app is
ready. Replaces the push-only useNativePushDeepLink and keeps backwards
compatibility with bare sessionId payloads.

Register the openchamber:// scheme in Info.plist.

Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding
Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new
ios-sim-build script that temporarily drops the MLKit barcode-scanning pod
(no arm64-simulator slice) so the app builds an arm64 binary installable on
Apple Silicon simulators, then restores the Podfile + Pods for device builds.
QR scanning already degrades cleanly when the native plugin is absent.

* feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh

Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension
(OpenChamberNotificationService), wired into the Xcode project, sharing an App
Group with the app.

Widgets:
- Overview (medium): recent sessions with read/unread dots + four quick actions
  (new, status, instances, settings).
- Sessions (large): session list with per-session project label, attention count
  and a new-session button in the header.
- Quick Actions (small): New chat pill + status/instances.
- Lock Screen (accessoryCircular x2): brand logo to new session, attention counter.
- Control Center control: brand logo (custom SF Symbol) to new session.

Data: the app writes a session-overview snapshot (attention count + recent
sessions with project labels) to the App Group on scene activate/resign; the NSE
refreshes it from each push (aps.badge + sessionId) so widgets update even when
the app is closed (needs aps mutable-content, added to the server + relay).

Deep links: add openchamber://status (session status panel) and reuse
view/instances; all widget taps route through the existing deep-link channel.

* feat(mobile): large Sessions widget lists 6 sessions with project labels

* feat(mobile): edge-swipe to switch sessions with directional slide+fade

* fix(mobile): keep widgets in sync via reload-on-change + periodic refresh

Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the
large Sessions widget stale (no unread dot / attention count) while medium updated.
Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when
the session overview actually changed (so we don't burn the budget on every scene
activate/resign), and give each widget a periodic timeline refresh so a missed
reload self-corrects.

* feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan

Cosmetics:
- Status bar: on Android inset the WebView below the bar (overlay:false) and
  paint it with the resolved theme background + correct content Style, since
  Android doesn't feed env(safe-area-inset-top) to CSS.
- Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes
  natively, so applying it double-counted and floated the composer); declare
  windowSoftInputMode=adjustResize and disable the shell height transition on
  Android so the header no longer bounces on keyboard open.

Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable
on Android (events only arrive once a run finishes). Forced in sync-context and
the other options are disabled in the Chat settings UI.

Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications
register() needs Firebase/FCM (not configured) and crashes at launch.

QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and
install/await the Google barcode scanner module (with a post-install retry) before
scanning so the first scan works without a manual retry.

Icon: Android adaptive launcher icon generated from the cube logo (full-bleed
white background, no edge artifact on One UI). Source assets under mobile/assets.

Tooling: adb-based android-device.mjs + android:* scripts for device deploy.

* feat(notifications): presence-aware push routing (don't spam the phone)

Only push to a device when the notification would otherwise be missed there. A
notification is suppressed on devices where the user is already present.

- Tag every client's visibility beacon and web-push subscription with a platform
  ('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform().
- Server tracks visibility per client (keyed by oc_ui_session) with the platform,
  and exposes isAnyInteractiveClientVisible() = any visible non-mobile client.
- Native push (APNs) and mobile PWA web-push are now suppressed when an
  interactive (desktop/web/vscode) client is visible — it already shows the
  in-app notification. Gated on the desktop's visibility (reliable), never the
  phone's own (a backgrounded WKWebView can't report "hidden").
- Desktop/web web-push keeps the any-visible gate (a visible client absorbs it).
- Skipping APNs also skips the badge increment so it doesn't drift.

Fixes the case where every session on a shared instance pushed to the phone even
while the user was actively working on desktop.

* feat(mobile): Android FCM push notifications

Enable native background push on Android via Firebase Cloud Messaging, in parallel
with the existing iOS APNs path.

- Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google
  Services Gradle plugin is applied when the file is present, so register() returns
  an FCM token instead of crashing.
- Un-gate native push registration to iOS OR Android, and tag the registered token
  with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM.
- Server stores the platform per device token and binds it to the relay (platform
  included in the signed register message).
- Notification small icon: monochrome cube silhouette with a mark on the top face,
  set as the FCM default_notification_icon so the status-bar icon reads as the logo.

Relay-side FCM sending ships in openchamber-website.

* docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap

* chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag)

- Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App
  Group snapshot shared with the widget + notification service extension) and wire it into the
  App target's resources — Apple requires an app-level privacy manifest.
- Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt.
- HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items).

Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy
bundled into App.app.

* refactor(mobile): dedupe capacitor detection + make beacon guard explicit

Addresses non-blocking PR review notes:
- Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation,
  usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform.
- usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on
  isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes.
2026-07-01 09:55:41 +03:00

30 KiB

OpenChamber - AI Agent Reference

Core purpose

OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). Official OpenCode traffic goes through @opencode-ai/sdk; OpenChamber-owned runtime capabilities go through RuntimeAPIs, runtimeFetch, and browser/realtime URL helpers.

Runtime architecture (IMPORTANT)

  • Desktop (Electron) boots the web server in the same Node process as the Electron main, then loads the web UI from http://127.0.0.1:<port>. No sidecar subprocess.
  • Backend/domain logic lives in packages/web/server/* (and packages/vscode/* for VS Code bridge/runtime parity). Electron owns the desktop shell/security boundary: windows, menus, dialogs, notifications, updater, deep-links, runtime host switching, local IPC gates, and SSH/tunnel management.
  • Do not add OpenCode feature backends to the native shell. Shared UI features should remain server/runtime APIs unless the capability is inherently native.

Desktop Shell

  • Desktop work goes into packages/electron/.
  • Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in packages/electron/main.mjs + packages/electron/preload.mjs.
  • Electron imports the server via @openchamber/web/server/index.js (workspace dep) and calls startWebUiServer({...}). The returned handle has getPort() / stop(). Notifications flow via an onDesktopNotification callback injected at startup — no stdout-parsing IPC.
  • Windows OS integrations must avoid console-window flashes. Any non-user-visible child_process call on Windows (system probes, tool discovery, updater/install helpers, SSH/tunnel helpers, cleanup, etc.) should run the target executable directly with windowsHide: true; detached/background helpers usually also need stdio: 'ignore'. Avoid cmd.exe /c pipelines and wrappers that spawn console grandchildren (taskkill, ping, nested powershell, batch shims), because windowsHide only reliably applies to the first child. If a delayed/background operation must outlive the app process, use a single hidden first-level helper (for example powershell.exe -WindowStyle Hidden -EncodedCommand ...) or a native Node/Electron API. Only omit this for intentionally user-visible shells/apps.
  • Build/release: Electron is the desktop release target.

Tech stack (source of truth: package.json, resolved: bun.lock)

  • Runtime/tooling: Bun (package.json packageManager), Node >=22 (package.json engines)
  • UI: React, TypeScript, Vite, Tailwind v4
  • State: Zustand stores and sync layer (packages/ui/src/stores/, packages/ui/src/sync/)
  • UI primitives: Base UI (@base-ui/react, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in packages/ui/src/components/ui/), Radix UI (package.json deps, legacy usages being migrated), HeroUI (package.json deps), Remixicon as SVG sprite source only (use shared Icon, never direct @remixicon/react imports)
  • Server: Express (packages/web/server/index.js)
  • Desktop: Electron 41 (packages/electron/)
  • VS Code: extension + webview (packages/vscode/)

Monorepo layout

Workspaces are packages/* (see package.json).

  • Shared UI: packages/ui
  • Web app + server + CLI: packages/web
  • Desktop shell: packages/electron
  • VS Code extension: packages/vscode

Documentation map

Before changing any mapped module, read its module documentation first.

web

Web runtime and server implementation for OpenChamber.

lib

Server-side integration modules used by API routes and runtime services.

event-stream

OpenChamber-owned event stream helpers for server-sent runtime events.

  • Module docs: packages/web/server/lib/event-stream/DOCUMENTATION.md
fs

Filesystem routes, raw file access, search helpers, and workspace-scoped file operations.

  • Module docs: packages/web/server/lib/fs/DOCUMENTATION.md
quota

Quota provider registry, dispatch, and provider integrations for usage endpoints.

  • Module docs: packages/web/server/lib/quota/DOCUMENTATION.md
git

Git repository operations for the web server runtime.

  • Module docs: packages/web/server/lib/git/DOCUMENTATION.md
github

GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.

  • Module docs: packages/web/server/lib/github/DOCUMENTATION.md
opencode

OpenCode server integration utilities including config management, provider authentication, and UI authentication.

  • Module docs: packages/web/server/lib/opencode/DOCUMENTATION.md
notifications

Notification message preparation utilities for system notifications, including text truncation and optional summarization.

  • Module docs: packages/web/server/lib/notifications/DOCUMENTATION.md
scheduled-tasks

Scheduled task persistence, execution, and event fanout for recurring sessions.

  • Module docs: packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md
text

Text processing helpers shared by server-side routes and summarization flows.

  • Module docs: packages/web/server/lib/text/DOCUMENTATION.md
terminal

WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting.

  • Module docs: packages/web/server/lib/terminal/DOCUMENTATION.md
tts

Server-side text-to-speech services and summarization helpers for /api/tts/* endpoints.

  • Module docs: packages/web/server/lib/tts/DOCUMENTATION.md
tunnels

Tunnel provider setup and runtime helpers for exposing OpenChamber over remote URLs.

  • Module docs: packages/web/server/lib/tunnels/DOCUMENTATION.md
ui-auth

UI session auth, client tokens, URL-token scoping, passkey/reset flows, and route-level auth gates.

  • Module docs: packages/web/server/lib/ui-auth/DOCUMENTATION.md
skills-catalog

Skills catalog management including discovery, installation, and configuration of agent skill packages.

  • Module docs: packages/web/server/lib/skills-catalog/DOCUMENTATION.md

ui

Shared React UI, sync layer, runtime API contracts, and stores.

sync

Session synchronization, event pipeline, optimistic updates, caches, and live-state stores.

  • Module docs: packages/ui/src/sync/DOCUMENTATION.md

stores

Zustand store ownership, persistence expectations, and store-splitting guidance.

  • Module docs: packages/ui/src/stores/DOCUMENTATION.md

session sidebar

Session sidebar grouping, ordering, virtualization-adjacent behavior, and project/worktree display.

  • Module docs: packages/ui/src/components/session/sidebar/DOCUMENTATION.md

message parts

Chat message part rendering and message-row performance expectations.

  • Module docs: packages/ui/src/components/chat/message/parts/DOCUMENTATION.md

Build / dev commands (verified)

All scripts are in package.json.

  • Validate: bun run type-check, bun run lint
  • Build all: bun run build
  • Desktop build (Electron — primary): bun run electron:build
  • Desktop dev (Electron): bun run electron:dev
  • VS Code build: bun run vscode:build
  • Release smoke build: bun run release:test (shell script: scripts/test-release-build.sh)

Runtime entry points

  • Web bootstrap: packages/web/src/main.tsx
  • Web server: packages/web/server/index.js
  • Web CLI: packages/web/bin/cli.js (package bin: packages/web/package.json)
  • Desktop: packages/electron/main.mjs (boots the web server in-process via startWebUiServer, loads web UI over loopback; preload at packages/electron/preload.mjs exposes the desktop IPC bridge)
  • VS Code extension host: packages/vscode/src/extension.ts
  • VS Code webview bootstrap: packages/vscode/webview/main.tsx

OpenCode integration

  • UI client wrapper: packages/ui/src/lib/opencode/client.ts (imports @opencode-ai/sdk/v2)
  • Sync/event pipeline: app roots mount SyncProvider from packages/ui/src/sync/sync-context.tsx; OpenCode SSE/WS event handling lives in packages/ui/src/sync/event-pipeline.ts
  • Web server embeds/starts OpenCode server: packages/web/server/index.js (createOpencodeServer)
  • Web runtime filesystem endpoints: packages/web/server/lib/fs/routes.js, registered by packages/web/server/lib/opencode/feature-routes-runtime.js
  • External server support: Set OPENCODE_HOST (full base URL, e.g. http://hostname:4096) or OPENCODE_PORT, plus OPENCODE_SKIP_START=true, to connect to existing OpenCode instance

Key UI patterns (reference files)

  • Settings shell: packages/ui/src/components/views/SettingsView.tsx
  • Settings shared primitives: packages/ui/src/components/sections/shared/
  • Settings sections: packages/ui/src/components/sections/ (incl skills/)
  • Chat UI: packages/ui/src/components/chat/ and packages/ui/src/components/chat/message/
  • Theme + typography: packages/ui/src/lib/theme/, packages/ui/src/lib/typography.ts
  • Terminal UI: packages/ui/src/components/terminal/ (uses ghostty-web)

External / system integrations (active)

  • Runtime API contracts: packages/ui/src/lib/api/types.ts; React consumption via packages/ui/src/hooks/useRuntimeAPIs.ts
  • Runtime transport/auth: packages/ui/src/lib/runtime-fetch.ts, packages/ui/src/lib/runtime-url.ts, packages/ui/src/lib/runtime-auth.ts
  • Git: packages/ui/src/lib/gitApi.ts, packages/web/server/lib/git/service.js (simple-git)
  • Terminal PTY: packages/web/server/lib/terminal/runtime.js (bun-pty/node-pty)
  • Skills catalog: packages/web/server/lib/skills-catalog/, UI: packages/ui/src/components/sections/skills/

Agent constraints

  • Do not modify ../opencode (separate repo).
  • Do not run git/GitHub commands unless explicitly asked.
  • Keep baseline green (run bun run type-check, bun run lint before finalizing changes).

Agent code of conduct

  • Prefer the smallest correct change.
  • Preserve working behavior before improving structure.
  • Do not add cleverness where a direct implementation is enough.
  • Do not infer critical state from weak signals when a stronger source exists.
  • Do not encode policy only in UI; enforce it in core logic.
  • Do not hide data loss, partial failure, or fallback behavior. Make it explicit in code.
  • Finish work end-to-end: implementation, verification, and cleanup.

Development rules

  • Keep diffs tight; avoid drive-by refactors.
  • Follow local precedent; inspect nearby code before introducing new patterns.
  • Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts.
  • TypeScript: avoid any, blind casts, and shape guessing.
  • React: prefer function components + hooks; use classes only when required.
  • Control flow: prefer early returns and explicit branching over nested ternaries.
  • Styling: Tailwind v4, typography via packages/ui/src/lib/typography.ts, theme vars via packages/ui/src/lib/theme/.
  • Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns.
  • Toasts: use the wrapper from @/components/ui; do not import sonner directly in feature code.
  • No new deps unless asked.
  • Never add secrets or log sensitive data.

Architecture patterns

Thin entrypoints, focused modules

  • Keep orchestration entrypoints thin: index.js, bridge files, bootstrap files, provider roots.
  • Move route, domain, and runtime logic into focused modules with clear ownership.
  • Prefer dependency injection over hidden module coupling.
  • Add or update module documentation when ownership changes.

Strong source of truth

  • Prefer deterministic state over heuristics.
  • Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution.
  • If a fallback is necessary, scope it narrowly to the active entity and treat it as temporary.
  • Restore derived UI state from authoritative records. Example: restore model or agent from the latest user message, not assistant-side guesses.

Live state vs historical state

  • Derive live UI behavior from live state channels, not persisted history.
  • Use historical records to restore context, not to infer that work is still in progress.
  • If live state is delayed, use the narrowest possible transient fallback and clear it as soon as authoritative state arrives.

Cross-runtime parity

  • If web defines a route or payload contract that shared UI depends on, keep VS Code and desktop parity where applicable.
  • Shared behavior differences must be intentional and visible in code.
  • Do not ship a web-only assumption into shared UI.

Partial-failure-safe flows

  • Cross-directory and multi-entity operations must tolerate partial failure.
  • Prefer per-item results, rollback paths, or resumable cleanup over all-or-nothing assumptions.
  • Never leave optimistic state or local caches stranded after failure.

Distinguish fetch failure from empty success

Client API methods that feed authoritative state (bootstrap, reconnect resync, retry loops) must signal fetch failure distinctly from a successful-but-empty server response. A method that swallows errors and returns []/{}/null lets the caller delete or overwrite legitimate state on a transient network blip, indistinguishable from "the server says nothing here."

  • Decide which methods are authoritative. A method is authoritative if any caller uses its result to delete, clear, or replace persisted/sync state. UI-display-only methods (autocomplete, dropdowns, settings pages) can keep silent-empty fallback because the user's next action refreshes them.
  • For authoritative methods, pick one of two patterns — both already exist in the codebase, do not invent a third:
    • Throw on failure (e.g. listPendingPermissions, listPendingQuestions, listAgents, the unwrap() helper in packages/ui/src/sync/bootstrap.ts). Use this when the caller has an outer try/catch per logical block — the throw skips the block and preserves prior state.
    • Return T | null on failure, where null strictly means "fetch failed" (e.g. getSessionStatusForDirectory, the .catch(() => null) + early-return-on-null pattern at the per-session reconnect loop in sync-context.tsx). Use this when the caller has follow-up work that should still run when one fetch fails.
  • Never swallow inside the method while returning the same type as success. The SDK's {data, error} shape already does this silently — wrap with if (result.error) throw … so the failure can't be lost.
  • Verify the caller actually preserves state on failure. Adding the throw is only half the fix; the consumer must not run the "delete missing" / "overwrite" branch unless it knows the fetch succeeded. The relevant outer try/catch is often already there but dormant.
  • Retry loops require a failure signal. A for (let attempt = 0; attempt < 3; …) retry around a method that swallows to [] will run exactly once — the loop never sees an error.

This rule is the API-layer counterpart of "Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution." A fetch failure is the same kind of anomaly — don't let it masquerade as authoritative server state.

Reconnect-loop pacing

The SSE/WebSocket reconnect loop in packages/ui/src/sync/event-pipeline.ts retries indefinitely. To avoid burning battery and server load on dead/idle connections, the loop's pacing must respect three signals:

  • navigator.onLine: when the browser reports offline, use the long backoff cap (~60s) instead of the short one (~5s). The expected recovery path is the online event, not the next probe.
  • document.visibilityState: when hidden, use the long cap too. A backgrounded PWA shouldn't hammer the network at 1/5s; the browser may also throttle our timers, but state the intent in code rather than relying on it.
  • HTTP status of the last failure: permanent 4xx errors (401, 403, 404, …) don't recover from blind retry. Jump straight to the long cap instead of running the normal exponential path; otherwise a stale-path or expired-token client would put ~12 reqs/min on the server log forever. 408 (Request Timeout) and 429 (Too Many Requests) are retryable in spirit — let them go through normal backoff.
  • Consecutive failures: real exponential growth (base * 2^failures, clamped), not constant 500ms. A hard-down server should see geometrically fewer probes per minute over time.

The inter-attempt wait must be interruptible by online, visibility-becomes-visible, and the pipeline's abort signal — otherwise recovery is delayed by however long the current sleep had left to run.

CLI Parity and Safety Policy (MANDATORY)

Principle: policy-first, UX-second

All safety and correctness rules MUST be enforced in core command logic, independent of output mode.

Interactive/pretty UX (@clack/prompts) is a presentation layer only. It must never be the only place where validation or restriction is enforced.

Required parity across modes

The same functional outcome and safety gates MUST hold for all execution modes:

  • Interactive TTY (full Clack UX)
  • Non-interactive shells (piped/stdin-less automation)
  • --quiet
  • --json
  • Fully pre-specified flags (no prompts)

In all modes, invalid operations MUST fail with non-zero exit code and deterministic error semantics.

Non-negotiable rule

Do not rely on prompts to enforce policy.

  • Prompts MAY help users choose valid inputs.
  • Core validators MUST run even when prompts are unavailable or skipped.
  • --quiet suppresses non-essential output only; it does not weaken validation.
  • --json changes output shape only; it does not weaken validation.

Detailed Clack UX patterns (primitives, prompt gating, and implementation checklist) are defined in the clack-cli-patterns skill and should not be duplicated here.

Project Skills (MANDATORY)

Project skills live under .agents/skills/*/SKILL.md. Before editing, agents MUST load every skill whose trigger matches the work; if multiple rows apply, load all of them.

Work being done Required skill call
Terminal CLI commands, prompts, or output formatting, especially packages/web/bin/* skill({ name: "clack-cli-patterns" })
Shared UI data access, RuntimeAPIs, runtimeFetch, runtime-url, OpenCode SDK calls, VS Code bridges/proxies, authenticated browser assets, Electron runtime switching, or web server API endpoints skill({ name: "ui-api-decoupling" })
UI components, styling, visual elements, colors, buttons, or icons skill({ name: "theme-system" })
User-facing UI text: labels, buttons, placeholders, aria labels, empty/error/loading states, toasts, dialogs, settings copy, or navigation labels skill({ name: "locale-ui-patterns" })
Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings skill({ name: "settings-ui-patterns" })
Drag-to-reorder, sortable lists/chips/grids, or @dnd-kit behavior including touch/mobile and wrapping variable-width items skill({ name: "drag-to-reorder" })
iOS Simulator preview/control for the mobile app, serve-sim, simulator taps/typing/gestures/rotation, or headless install/launch workflows outside Xcode skill({ name: "serve-sim" })

Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes.

Performance rules (MANDATORY)

These rules exist because violating them has caused measurable regressions (render cascades, memory bloat, UI jank). They apply to all UI and sync layer work.

Shared-store render discipline

  • Treat common stores as render fanout boundaries. An unnecessary reference change in shared state can re-render large parts of the app.
  • Do not put high-frequency state in broadly consumed stores. Fast-changing state should live in narrow stores with narrow subscribers.
  • Update only the fields that changed. Preserve references for untouched state branches.
  • Prefer leaf selectors over container selectors. Subscribe to the smallest stable value that satisfies the component.
  • Isolate hot consumers. If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child.
  • Do not subscribe shell/layout components to broad live collections. If a shell only needs one field, entity, or derived flag, subscribe to that instead of the whole collection.
  • Treat provider roots as global hot paths. A top-level provider must not subscribe to high-frequency data unless the feature is actually enabled and the subscription is essential.

Zustand referential equality

Zustand skips re-renders when a selector returns the same reference (Object.is). Every new object/array reference triggers a re-render in every subscriber.

  • Never spread all state fields in an update. Only create new references for fields that actually changed. A message.part.delta event should not clone session, permission, etc.
  • Select leaf values, not containers. useStore((s) => s.permission[sessionID]) is correct. useStore((s) => s.permission) subscribes to every permission change across all sessions.
  • Preserve references when merging. If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added.
  • For derived collections, preserve item identity when presentation-relevant fields are unchanged. Reuse previous item references for unchanged rows/items and move high-frequency live fields to narrow per-item selectors.

Store splitting

A single store with N properties means every subscriber re-evaluates on every state change. Split stores by change frequency and subscriber set.

  • Group state by how often it changes. Streaming state (updated 60/sec) must not live with user preferences (updated on click).
  • Group state by who reads it. If only 2 components need a value, it belongs in a store that only those 2 subscribe to.
  • Cross-store reads use .getState(). Actions in one store that need another store call useOtherStore.getState() — imperative, no subscription.
  • Never add unrelated state to an existing store just because it's convenient. Create a new store.

Event pipeline and SSE

  • Gate expensive operations on the hot path. During streaming, message.part.delta and message.part.updated fire ~60/sec. Any findIndex, filter, or iteration added to these handlers multiplies across every event. Gate behind a cheap boolean check first (e.g., check next[0] before scanning the array).
  • Skip no-op updates. If an incoming event doesn't change the state (same role, same finish, same timestamps), return false from the reducer to avoid creating new references.
  • Coalesce by key. Same-entity events (e.g., repeated session.status for the same session) should replace earlier ones in the queue, not accumulate.
  • Preserve event ordering semantics. Reducers and queues must not let stale deltas or out-of-order events corrupt the latest state.
  • Do not widen live-activity fallbacks. A fallback for delayed status should inspect only the current trailing entity, not arbitrary historical records.

Polling payload fidelity

  • Do not let lightweight polling erase rich fields. If light mode omits fields (e.g., diffStats), preserve previous rich data until a heavy follow-up fetch lands.
  • Use two-phase polling. Run cheap change detection first; only run heavy status fetches for directories that actually changed.

Optimistic updates

  • Use the shadow Map pattern. Insert optimistic data into the store for instant UI, AND register it in a separate tracking Map. Cleanup happens deterministically via mergeOptimisticPage on the next data fetch — not via heuristics in the event reducer.
  • Pass client-generated IDs to the server. Use the same ID format as the server (hex-encoded timestamps). Pass messageID to promptAsync so the server echoes back the same ID. This prevents duplicates and enables in-place replacement.
  • Rollback on error. Remove the optimistic entry from both the store and the shadow Map.
  • Stabilize bridge callbacks. When wiring hook callbacks into module-level refs, use stable ref wrappers so effects do not loop on changing function identities.

Session/input consistency

  • Capture send config at queue time. Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time.
  • Keep server-selected attachments sendable. Preserve server-backed file selections in queue/submit flows and convert them to proper file:// URLs before sending.
  • Do not let text input state repaint unrelated chrome. Typing should not force unrelated controls, menus, indicators, or toolbars to re-render on every keystroke.
  • Extract slow-changing chrome from hot input paths. If controls do not depend on the current text value, move them behind memoized boundaries with stable callbacks.

Bootstrap resilience

  • Treat startup 502/503 as transient. Retry bootstrap/session-list flows with bounded retries/intervals, especially in VS Code where API readiness can lag bridge startup.
  • Use polling recovery when failures are swallowed. If an async loader resolves without throwing on failure, recover with interval retries gated by loaded-state checks.

Scroll and DOM

  • Never use await waitForFrames() for scroll preservation. Frames of visible scroll jump are unacceptable. Use useLayoutEffect to adjust scroll synchronously after React commits DOM — before the browser paints.
  • Capture scroll state before the state change, restore in layout effect. The pattern: save scrollHeight/scrollTop into a ref before triggering the update, consume it in useLayoutEffect on the rendered output.
  • Do not let viewport resizes masquerade as content growth. Viewport-height changes must not trigger the same scroll compensation logic used for actual content growth.
  • Disable or narrow native/browser scroll anchoring when custom scroll logic exists. Browser anchoring and app-managed pinning/follow logic will fight and produce jiggle.
  • Autosize textareas without transient collapse on growth. Avoid height='auto' shrink/expand cycles on every character when the content only grew; this creates visible layout bounce.

List ordering and view consistency

  • Do not sort structural lists directly from high-churn live fields. If live updates are frequent, sorting directly from them causes reorder thrash and wide rerender cascades.
  • If live recency is required, freeze order during high-frequency updates and apply a one-shot reorder only at an intentional lifecycle edge. Choose the lifecycle edge explicitly instead of letting every intermediate update reshuffle the UI.
  • Use one ordering source for all views of the same data. Different views of the same entities must derive from the same ranked list or rank map; do not let each surface re-derive ordering independently.
  • Do not mix global snapshots and local live snapshots without an explicit reconciliation policy. If multiple data sources feed one view, define which fields win and how they merge.

Component isolation

  • Extract high-frequency hook consumers into separate components. If a hook re-evaluates 60/sec (e.g., streaming status), wrap its consumer in a React.memo child component so the parent doesn't re-render.
  • Use custom React.memo comparators for message rows. Compare render-relevant fields (role, finish, parts count, part IDs) — not object references.

Caching and memory

  • Cap in-memory caches with both count and byte limits. Entry count alone doesn't prevent memory bloat from large files. Use dual-constraint LRU (e.g., 40 entries OR 20MB).
  • Set store session limits to match loaded data. If bootstrap loads N sessions, set limit >= N. Otherwise the next SSE event triggers trimming that silently removes sessions.
  • Invalidate caches on mutations. File content cache must clear entries on write, delete, rename. Prefetch cache must clear on session eviction.
  • Use TTLs to prevent redundant fetches. If a session was fetched <15s ago, skip re-fetching — SSE events keep it current.

Directory context

  • Never cache directory strings in closures. Directory can change at any time (worktree switch). Read it dynamically from opencodeClient.getDirectory() at call time.
  • Pass directory hints when the source of truth isn't available yet. Newly created sessions aren't in the sync store until SSE delivers them. Pass the known directory as a parameter instead of relying on lookup.

Regression-prevention checklist

  • When adding fallback logic, ask: can stale persisted data keep this path active forever?
  • When deriving UI state, ask: is this live state, historical state, or inferred state?
  • When adding store fields, ask: who reads this, how often does it change, and should it live elsewhere?
  • When touching polling or bootstrap, ask: can a lighter payload erase richer existing data?
  • When handling optimistic updates, ask: where is rollback, reconciliation, and duplicate prevention?
  • When changing shared routes or state contracts, ask: what breaks in web, desktop, and VS Code?
  • When fixing a bug with a heuristic, prefer narrowing the heuristic over widening it.

Validation expectations

  • Run type-check/lint validation before finalizing source-code changes that can affect TypeScript, runtime behavior, builds, lint rules, package resolution, or generated assets, and run bun run dead-code when the change can add, remove, rename, or reshape files, exports, types, workspace entrypoints, or module imports. Keep validation scoped to the edited workspace by default. Prefer the package-level command for the package you changed (for example the relevant workspace's type-check/lint) instead of workspace-wide bun run type-check / bun run lint. Use workspace-wide checks only when the change spans multiple workspaces, shared package contracts, root tooling/config, dependency resolution, generated assets used across packages, or when a narrower command cannot cover the risk. Use a sufficiently long tool timeout for any broad checks (for example 240000ms) so successful package-level results are not lost to a tool timeout. For docs-only or isolated config-only changes, run the narrowest relevant validation instead (for example JSON/schema validation) and do not run full checks unless the change can affect code execution.
  • For hot-path changes, verify behavior under streaming or repeated events, not just static render.
  • For sync or startup changes, verify fresh load, retry/failure, and restart behavior.
  • For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant.

Recent changes

  • Releases + high-level changes: CHANGELOG.md
  • Recent commits: git log --oneline (latest tags: v1.11.7, v1.11.6)