diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index afc2ac4..2cda86d 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms -github: [jacobjmc, dimillian] +github: [jacobjmc] buy_me_a_coffee: jacobjmc diff --git a/README.md b/README.md index 933acd6..60aa6d5 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,11 @@ npm run tauri:build OpenCode Monitor is built on top of [CodexMonitor](https://github.com/Dimillian/CodexMonitor) by [Thomas Ricouard](https://github.com/Dimillian). **Support the original author:** + - [Sponsor Thomas on GitHub](https://github.com/sponsors/Dimillian) -- [Ice Cubes for Mastodon](https://apps.apple.com/app/ice-cubes-for-mastodon/id6444915884) **Support this fork:** + - [Buy me a coffee](https://buymeacoffee.com/jacobjmc) ## License diff --git a/docs/assets/app-icon.png b/docs/assets/app-icon.png deleted file mode 100644 index 4cb272a..0000000 Binary files a/docs/assets/app-icon.png and /dev/null differ diff --git a/docs/assets/feature1.png b/docs/assets/feature1.png deleted file mode 100644 index 0d16ed1..0000000 Binary files a/docs/assets/feature1.png and /dev/null differ diff --git a/docs/assets/feature2.png b/docs/assets/feature2.png deleted file mode 100644 index 66be998..0000000 Binary files a/docs/assets/feature2.png and /dev/null differ diff --git a/docs/assets/feature3.png b/docs/assets/feature3.png deleted file mode 100644 index 10666b2..0000000 Binary files a/docs/assets/feature3.png and /dev/null differ diff --git a/docs/assets/feature4.png b/docs/assets/feature4.png deleted file mode 100644 index 354c8c1..0000000 Binary files a/docs/assets/feature4.png and /dev/null differ diff --git a/docs/assets/feature5.png b/docs/assets/feature5.png deleted file mode 100644 index 1e648a3..0000000 Binary files a/docs/assets/feature5.png and /dev/null differ diff --git a/docs/assets/feature6.png b/docs/assets/feature6.png deleted file mode 100644 index c966cfc..0000000 Binary files a/docs/assets/feature6.png and /dev/null differ diff --git a/docs/assets/main.png b/docs/assets/main.png deleted file mode 100644 index dc176a2..0000000 Binary files a/docs/assets/main.png and /dev/null differ diff --git a/docs/assets/screenshot.png b/docs/assets/screenshot.png deleted file mode 100644 index dc176a2..0000000 Binary files a/docs/assets/screenshot.png and /dev/null differ diff --git a/docs/changelog.html b/docs/changelog.html deleted file mode 100644 index 9da51a3..0000000 --- a/docs/changelog.html +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - OpenCode Monitor - Changelog - - - - - - - - - - - - - - - - -
-
-
- - - -
-
-
-
-
Release notes
-

Changelog

-
-
-
- -
-
-
Loading releases...
-
-
-
-
- - - - - - diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 0124bd1..0000000 --- a/docs/index.html +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - OpenCode Monitor - Orchestrate OpenCode agents across your workspaces - - - - - - - - - - - - - - - - -
-
-
- - - -
-
-
-
-
Desktop command center for OpenCode
-

Monitor your OpenCode work.

-

- Orchestrate any number of OpenCode agents across any number of projects in a beautifully crafted - command center for threads, reviews, and worktrees. -

- -
-
- Built on - Tauri + React -
-
- Platform - macOS + Windows + Linux -
-
-
- -
- OpenCode Monitor dashboard and chat view -
-
-
- -
-
-
-

Everything you need to run OpenCode like a studio.

-

- Build, review, and ship across multiple repos without losing context. OpenCode Monitor keeps the - entire workflow in one surface. -

-
- -
-
-

Workspace orchestration

-

Add, persist, and reconnect workspaces with a live dashboard of recent activity.

-
-
-

Thread control

-

Start threads, resume history, and handle approvals without leaving the main view.

-
-
-

Worktree agents

-

Spin up per-branch worktrees for isolated changes and clean reviews.

-
-
-

Git + GitHub insight

-

Diffs, logs, branch controls, and Issues via gh built right in.

-
-
-

Model + access controls

-

Pick models, tune reasoning effort, and lock in access modes per run.

-
-
-

Plans and reviews

-

Track per-turn plans, run reviews, and export debug logs on demand.

-
-
-

Skills + prompts

-

Autocomplete skills, prompts, and file tokens in the composer.

-
-
-

Updater + polish

-

Toast-driven updates, resizable panels, and platform-specific window chrome.

-
-
-
-
- -
-
-
-

The next-generation IDE.

-

Without a text editor built in, because you don’t need it.

-
- -
-
-
- OpenCode Monitor full interface -
-
- Command center layout - Threads, reviews, and activity stacked into one view. -
-
- -
-
- Beautiful chat interface and integrated terminal -
-
- Conversation + terminal - Ask, approve, and run commands with context attached. -
-
- -
-
- Projects and OpenCode threads overview -
-
- Projects hub - Keep every workspace, thread, and run in view. -
-
- -
-
- Git diff, log, and issues all built in -
-
- Git + issues - Diffs, logs, and GitHub issues in one dock. -
-
- -
-
- Files tree with search -
-
- Files tree - Search and jump through large repos quickly. -
-
- -
-
- Beautiful live git diff -
-
- Beautiful live git diff - Real-time diff visualization with syntax highlighting. -
-
- -
-
- Skills support -
-
- Skills support - Autocomplete and use skills seamlessly in your workflow. -
-
-
-
-
- -
-
-
-

From repo to review in three moves.

-
-
-
- 01 -

Connect your workspaces

-

OpenCode Monitor starts an opencode serve backend per workspace and restores your threads.

-
-
- 02 -

Run the agent

-

Pick models, access mode, and skills, then chat and manage approvals in one view.

-
-
- 03 -

Review + ship

-

Open diffs, logs, and reviews alongside your threads with instant context.

-
-
-
-
- -
-
-
-
-

Ready to monitor every agent run?

-

OpenCode Monitor keeps your OpenCode workflows visible, organized, and fast.

-
-
- View repo -
-
-
-
-
- - - - diff --git a/docs/mobile-ios-cloudflare-blueprint.md b/docs/mobile-ios-cloudflare-blueprint.md deleted file mode 100644 index fc5ab64..0000000 --- a/docs/mobile-ios-cloudflare-blueprint.md +++ /dev/null @@ -1,537 +0,0 @@ -# CodexMonitor iOS Remote Blueprint (Orbit + Tailscale Bootstrap) - -This document is the canonical implementation plan for shipping CodexMonitor on iOS with a Tailscale-first bootstrap path and Orbit on Cloudflare as the production relay/control plane to a macOS runner. - -## Scope - -- Build and ship a real iOS app (Tauri mobile target). -- Keep macOS as the execution host (Codex binary, repos, git, terminals, files). -- Provide a low-friction self-host bootstrap path via Tailscale + TCP daemon for early end-to-end mobile testing. -- Use Orbit on Cloudflare as secure relay/realtime bridge between iOS and macOS. -- Make macOS setup manageable from CodexMonitor Settings: authenticate, pair device, launch/stop runner, inspect status/logs. -- Keep one backend logic path (shared core + daemon). Do not duplicate backend behavior in iOS UI. - -## Non-Goals (This Plan) - -- Building a custom Cloudflare Worker/DO protocol for relay. -- Defining a custom bridge envelope (`seq`/`ack`) as a required transport contract. -- Reintroducing CloudKit/PR31-based remote architecture. - -## Current State (Important) - -- Tauri app is desktop-first with `#[cfg_attr(mobile, tauri::mobile_entry_point)]` already present in `src-tauri/src/lib.rs`. -- `remote_backend` has been refactored into pluggable transport modules: - - `src-tauri/src/remote_backend/mod.rs` - - `src-tauri/src/remote_backend/protocol.rs` - - `src-tauri/src/remote_backend/transport.rs` - - `src-tauri/src/remote_backend/tcp_transport.rs` - - `src-tauri/src/remote_backend/orbit_ws_transport.rs` -- Current transport behavior: - - TCP transport remains intact (existing remote path preserved). - - Orbit WS transport is implemented for connect/read/write + request/response routing, including line-delimited frame splitting. - - App transport is currently single-connection (no client-side reconnect loop yet). - - Daemon Orbit runner mode includes reconnect/backoff for outbound Orbit WS. -- Remote provider settings baseline is implemented: - - `remoteBackendProvider`: `"tcp" | "orbit"` - - `remoteBackendHost`, `remoteBackendToken` - - `orbitWsUrl`, `orbitAuthUrl` - - `orbitRunnerName`, `orbitAutoStartRunner` - - `orbitUseAccess`, `orbitAccessClientId`, `orbitAccessClientSecretRef` -- Orbit remote operations are implemented in app and daemon wiring via shared core: - - `orbit_connect_test` - - `orbit_sign_in_start` - - `orbit_sign_in_poll` (stores token to app settings on authorization) - - `orbit_sign_out` (best-effort logout + token clear) - - `orbit_runner_start` - - `orbit_runner_stop` - - `orbit_runner_status` -- Settings UI now includes Orbit provider setup/actions in `SettingsView`: - - URLs, runner name, access fields, connect/sign-in/sign-out, runner start/stop/status - - inline device-code polling flow wired to `orbit_sign_in_poll` -- Remote notification forwarding currently handles only: - - `app-server-event` - - `terminal-output` - - `terminal-exit` -- Mobile UI scope is the existing app layout in mobile form-factor (no separate mobile-only feature surface). -- Shared-core parity refactor is in place for prompts, local usage, codex utility helpers, git/github UI helpers, and workspace actions. -- Tailscale setup helper is implemented for TCP remote mode: - - Desktop command: `tailscale_status` - - Desktop command: `tailscale_daemon_command_preview` - - Settings UI helpers: detect Tailscale, use suggested tailnet host, show daemon launch command template -- Daemon RPC parity for the current mobile scope is complete. -- `terminal_*` and `dictation_*` command parity are intentionally out of scope for this mobile phase. - -## Target Architecture - -## Components - -1. iOS App (Tauri) -- UI + local state + IPC wrappers. -- Uses remote mode only (no local codex execution). -- Connects to Orbit WebSocket endpoint and consumes Codex JSON-RPC stream. - -2. macOS App + Daemon Runner -- Runs all backend operations (shared cores, codex process, files/git/terminal). -- Maintains outbound connection to Orbit. -- Receives JSON-RPC from Orbit and returns results/events. - -3. Tailscale Tailnet (Bootstrap Path) -- iOS and macOS join the same user-managed tailnet. -- iOS connects directly to daemon TCP endpoint over tailnet (`remoteBackendProvider=tcp`). -- No hosted CodexMonitor service required. - -4. Orbit Cloud Services -- Auth service (passkey + JWT/session). -- Orbit relay (Worker + Durable Object routing + event persistence endpoint). -- User-owned self-host deployment path only. - -## Canonical Protocol Choice - -- Use Orbit JSON-RPC relay model plus Orbit control messages (`orbit.subscribe`, `orbit.unsubscribe`, `orbit.list-anchors`, keepalive ping/pong). -- For Tailscale bootstrap mode, continue using existing TCP JSON-RPC over tailnet (`remoteBackendProvider=tcp`) with token auth. -- Do not introduce a second custom transport protocol for this phase. -- Reconnection/resync should use Orbit thread event history endpoint and thread resume flows. - -## Data Flow - -1. macOS runner authenticates and opens persistent WS to Orbit. -2. iOS app authenticates and opens WS to Orbit. -3. iOS subscribes to thread channels via `orbit.subscribe`. -4. iOS sends JSON-RPC `invoke` messages (for example `thread/start`, `turn/start`) through Orbit. -5. Orbit relays to runner. -6. Runner executes daemon RPC / app-server operations. -7. Orbit relays results and notifications back to subscribed iOS clients. -8. On reconnect, iOS reloads state from thread resume + stored events endpoint. - -## Orbit Deployment Model - -## Self-Hosted Orbit Only - -- User deploys Orbit/Auth workers and D1 with Wrangler. -- User provides Orbit/Auth endpoints in Settings. -- Pair/auth flows remain the same once endpoints are configured. - -## Required Backend Refactor in CodexMonitor - -## 1) Refactor `remote_backend` to pluggable transport - -Target: keep existing `call_remote(...)` callsites while replacing transport internals. - -Implemented structure: - -- `src-tauri/src/remote_backend/mod.rs` -- `src-tauri/src/remote_backend/protocol.rs` -- `src-tauri/src/remote_backend/transport.rs` (trait) -- `src-tauri/src/remote_backend/tcp_transport.rs` (legacy/dev) -- `src-tauri/src/remote_backend/orbit_ws_transport.rs` (new) - -`RemoteTransport` trait: - -- `connect(config) -> Client` -- `send(request) -> pending result` -- `subscribe_events() -> stream` -- `close()` -- `status()` - -Current status: - -- Done: transport split + provider routing + Orbit WS connect/read/write path. -- Done: WebSocket payload parsing split to protocol lines before JSON-RPC dispatch. -- Pending: app-side reconnect strategy and replay/resync contract integration. - -## 2) Add bridge configuration to settings model - -Extend `AppSettings` in `src-tauri/src/types.rs` and UI types in `src/types.ts`. - -Implemented baseline fields: - -- `remoteBackendProvider`: `"tcp" | "orbit"` -- `remoteBackendHost` -- `remoteBackendToken` -- `orbitWsUrl` -- `orbitAuthUrl` -- `orbitRunnerName` -- `orbitAutoStartRunner` -- `orbitUseAccess` -- `orbitAccessClientId` -- `orbitAccessClientSecretRef` - -Planned next (not yet implemented in settings model): - -- deployment/auth/pairing metadata required for full self-host Orbit UX -- secure-storage integration for secret material lifecycle (set/reset/rotation) - -Keep secrets out of plain `settings.json` where possible. - -## 3) Secret storage - -Implement secure secret storage adapter: - -- macOS: Keychain via Rust crate (`keyring`) or dedicated secure-storage layer. -- iOS: Keychain-backed storage for mobile credentials. - -Store only secret references/aliases in app settings JSON. - -## 4) Runner service manager (macOS) - -Add backend service manager module: - -- `src-tauri/src/bridge_runner/mod.rs` - -Responsibilities: - -- Start runner process/task. -- Stop runner. -- Report health (`connecting|online|offline|error`). -- Persist last logs ring buffer. -- Auto-start on app launch if enabled. - -Current implementation: - -- Basic runner lifecycle controls are implemented via Tauri commands in `src-tauri/src/orbit/mod.rs` and daemon Orbit mode args in `src-tauri/src/bin/codex_monitor_daemon.rs`. -- Full background service management (LaunchAgent install/remove, log viewer, lifecycle recovery after app restart) remains pending. - -Potential implementations: - -- Embedded task in app process (faster iteration). -- Optional LaunchAgent installation for background persistence across app restarts. - -## 5) Daemon Orbit mode - -Extend daemon binary (`src-tauri/src/bin/codex_monitor_daemon.rs`) with optional Orbit connector mode. - -Representative options: - -- `--orbit-url` -- `--orbit-auth-url` -- `--orbit-device-login` -- `--orbit-token-ref` - -Behavior: - -- Outbound WS to Orbit relay. -- Translate Orbit-relayed JSON-RPC to existing RPC handler + event bus. -- Support runner reconnect and re-subscription behavior. - -Current implementation status: - -- Orbit mode args are implemented: `--orbit-url`, `--orbit-token`, `--orbit-auth-url`, `--orbit-runner-name`. -- Orbit mode loop is implemented with reconnect/backoff, event forwarding, ping/pong handling, and `anchor.hello` metadata send. -- Further Orbit-specific subscription/replay semantics remain pending until mobile Orbit client wiring is added. - -## 6) Command parity scope (mobile phase) - -Remote mode must support all commands exercised by the current mobile UI surface. - -Implemented in shared core + daemon/app adapters: - -- Git + GitHub UI commands: - - `list_git_roots`, `get_git_status`, `get_git_diffs`, `get_git_log`, `get_git_commit_diff`, `get_git_remote` - - `list_git_branches`, `checkout_git_branch`, `create_git_branch` - - `stage_git_file`, `stage_git_all`, `unstage_git_file` - - `revert_git_file`, `revert_git_all` - - `commit_git`, `push_git`, `pull_git`, `fetch_git`, `sync_git` - - GitHub issues/PRs/comments/diff commands -- Prompts commands: - - `prompts_list`, `prompts_create`, `prompts_update`, `prompts_delete`, `prompts_move`, `prompts_workspace_dir`, `prompts_global_dir` -- Workspace/app extras: - - `add_clone`, `apply_worktree_changes`, `open_workspace_in`, `get_open_app_icon` -- Utility commands: - - `codex_doctor`, `generate_commit_message`, `generate_run_metadata`, `local_usage_snapshot`, `send_notification_fallback`, `is_macos_debug_build`, `menu_set_accelerators` - -Out of scope for this mobile phase: - -- Terminal commands: - - `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close` -- Dictation commands: - - `dictation_model_status`, `dictation_download_model`, `dictation_cancel_download`, `dictation_remove_model`, `dictation_start`, `dictation_request_permission`, `dictation_stop`, `dictation_cancel` - -Validation policy: - -- No CI parity guard is required for this phase. -- Validate parity locally before merge (build/tests + remote-mode smoke checks). - -## Frontend Plan - -## Settings UX (required for easy setup) - -Update `src/features/settings/components/SettingsView.tsx` to add an Orbit section when `backendMode=remote` and provider is orbit. - -Required controls: - -- Provider selector (`TCP daemon` / `Orbit`) -- TCP + Tailscale helpers: - - `Detect Tailscale` - - `Use suggested host` - - daemon launch command template -- Orbit WS URL input -- Orbit Auth URL input -- Runner name input -- Access auth toggle + client id input + secret set/reset (optional) -- `Connect test` button -- `Sign In` / `Sign Out` actions -- `Start Runner` / `Stop Runner` buttons -- `Install LaunchAgent` / `Remove LaunchAgent` (optional) -- Status badge + last heartbeat + error message -- `Copy Pair Code` / `Show QR` -- `View Logs` drawer - -Current implementation status: - -- Implemented now: - - Provider selector - - TCP Tailscale helper controls (`Detect Tailscale`, suggested host, daemon command template) - - Orbit WS/Auth URL inputs - - Runner name input - - Access toggle + client id/secret ref fields - - `Connect test`, `Sign In`, `Sign Out`, `Start Runner`, `Stop Runner`, `Refresh Status` - - inline status/auth-code/verification URL display -- Pending: - - LaunchAgent install/remove controls - - status badge with heartbeat metadata - - `Copy Pair Code` / `Show QR` - - logs drawer UI - -UX behavior: - -- Disable invalid combinations. -- Show clear actionable errors (auth failed, runner offline, endpoint invalid, token expired). -- Persist non-secret fields immediately. -- Save secrets via secure backend command only. - -## iOS client UX - -- First launch setup: - - endpoint-aware sign-in (self-host) - - `Scan QR` / `Enter pair code` - - Recent sessions -- Runtime status: - - `Connected to ` - - Latency indicator - - Reconnecting state -- Conflict handling: - - Runner offline banner - - Rehydration state after reconnect - -## User Setup Flows - -## Tailscale Bootstrap (Implemented) - -Desktop setup: - -1. Install Tailscale and sign into the same tailnet on desktop and iPhone. -2. In CodexMonitor Settings, set `Backend Mode = Remote`, `Provider = TCP`. -3. Click `Detect Tailscale` and then `Use suggested host`. -4. Set a `Remote backend token`. -5. Copy the generated daemon command template and run it on desktop. -6. Use the same host/token in mobile app remote settings. - -Mobile setup: - -1. Install and sign into Tailscale on iOS. -2. Open CodexMonitor iOS app. -3. Set remote provider to TCP and enter tailnet host + token from desktop setup. -4. Connect and validate thread list + messaging. - -## Self-Hosted Orbit - -Desktop setup: - -1. Deploy Orbit/Auth services to Cloudflare. -2. Open CodexMonitor Settings. -3. Set `Backend Mode = Remote`, `Provider = Orbit`. -4. Enter `Orbit WS URL` and `Orbit Auth URL`. -5. Configure optional Access credentials. -6. Sign in and start runner. -7. Pair mobile via QR/code. - -Mobile setup: - -1. Launch iOS app. -2. Sign in against configured self-host auth. -3. Scan QR or enter pair code. -4. Store credentials in Keychain and auto-connect. - -User-provided information: - -- Orbit WS URL. -- Orbit Auth URL. -- Optional Access client credentials (if enabled). - -## Mobile-safe UI readiness - -Current responsive layouts exist (`phone`, `tablet`, `desktop`), but ensure: - -- touch target sizes are >= 44pt -- no hover-only actions for critical controls -- keyboard-safe composer on iOS (safe area + bottom inset) -- panel resizing gestures disabled on touch layouts - -## iOS Build + Install Runbook - -## Prerequisites (macOS) - -1. Xcode (full app, not only CLT). -2. Rust iOS targets: - -```bash -rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim -``` - -3. CocoaPods: - -```bash -brew install cocoapods -``` - -4. JS dependencies from repo root: - -```bash -npm install -``` - -## Initialize iOS project files - -From repo root: - -```bash -npm run tauri ios init -``` - -Expected output: -- `src-tauri/gen/apple/*` generated. -- Xcode project/workspace for iOS target available. - -## Run on iOS Simulator (dev) - -```bash -npm run tauri ios dev -``` - -Notes: -- Uses `build.devUrl` and `beforeDevCommand`. -- Rust + frontend hot-reload loop in dev. - -## Run on Physical Device (dev) - -1. Open generated Xcode workspace. -2. Set Apple Team + signing profile for iOS target. -3. Ensure frontend dev server reachable from device network. -4. Run: - -```bash -npm run tauri ios dev -- -``` - -If network issues appear, ensure dev server listens on host interface and uses `TAURI_DEV_HOST` when set. - -## Build production iOS app - -```bash -npm run tauri ios build -``` - -Output: -- Release build artifacts/IPA via Tauri iOS build flow. - -## Install build - -Development install options: - -1. Xcode run to connected device. -2. Xcode Organizer distribute to internal testers. -3. TestFlight (recommended for team validation). - -For direct IPA sideload in controlled environments, use Apple Configurator or MDM as appropriate. - -## Tauri and Cargo Changes Required for iOS Compatibility - -## Cargo dependency gating - -In `src-tauri/Cargo.toml`, gate non-mobile dependencies behind desktop cfg where needed (for example terminal/generic git native deps if unsupported on iOS runtime path). - -## Tauri config split - -Create and maintain iOS-specific config (`src-tauri/tauri.ios.conf.json`) for: - -- iOS bundle identifiers -- iOS icons/assets -- iOS permissions usage strings -- iOS-specific plugin toggles - -Keep desktop-only settings out of iOS config (titlebar/private APIs/updater artifacts). - -## Backend module gating - -Use `cfg` for mobile-safe stubs where functionality is desktop-only, while preserving command signatures used by frontend. - -## Testing and Validation Matrix - -## Unit/Type/Lint - -From repo root: - -```bash -npm run lint -npm run typecheck -npm run test -``` - -If Rust touched: - -```bash -cd src-tauri -cargo check -cargo test -``` - -## Orbit integration tests - -- Simulate iOS disconnect/reconnect. -- Verify thread rehydration via resume/events endpoint. -- Verify idempotent handling of duplicate RPC responses. -- Verify unauthorized client rejection. -- Verify runner failover from offline -> online. -- Verify thread subscription behavior (`orbit.subscribe`/`orbit.unsubscribe`). - -## Manual scenario checklist - -1. Pair iOS with macOS runner. -2. List workspaces. -3. Connect workspace. -4. Start thread, send messages, interrupt turn. -5. Git diff panel operations. -6. Prompts CRUD. -7. Verify terminal UI is not exposed in mobile mode. -8. Verify dictation UI is not exposed in mobile mode. -9. Background iOS app, resume, ensure state resync. -10. macOS runner restart, iOS auto-reconnect. - -## Implementation Milestones - -1. Milestone A: iOS compile baseline + mobile-safe stubs. -2. Milestone B: Orbit integration baseline (self-host config path). -3. Milestone C: `remote_backend` transport refactor + Orbit WS transport + runner Orbit mode. -4. Milestone D: daemon parity closure for mobile scope (excluding terminal/dictation). -5. Milestone E: Settings UX/service manager + pairing UX. -6. Milestone F: full E2E validation and TestFlight beta. - -## Definition of Done - -- iOS app can fully control a macOS runner via Orbit bridge. -- Remote feature parity with desktop local mode for supported workflows. -- macOS users can configure Orbit from Settings using self-hosted Orbit endpoints. -- Runner can be started/stopped/auto-started from app. -- Reconnect/resync is robust and observable. -- Build/install flow is documented and reproducible. - -## Fresh-Agent Execution Checklist - -1. Read this document completely. -2. Implement Milestone A first and ensure local iOS dev build works. -3. Integrate Orbit transport/auth in isolation with mock runner/client tests. -4. Refactor `remote_backend` to transport abstraction. -5. Complete daemon parity for mobile scope and validate locally. -6. Build settings UX and runner service controls. -7. Validate full manual checklist on simulator and physical device. -8. Ship behind feature flag, then remove flag after beta validation. diff --git a/docs/shaping/multi-provider-sessions.md b/docs/shaping/multi-provider-sessions.md deleted file mode 100644 index 404ea6c..0000000 --- a/docs/shaping/multi-provider-sessions.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -shaping: true ---- - -# Multi-Provider Sessions — Shaping - -Handle session usage tracking when users can switch between API providers (Anthropic, OpenAI, etc.) and account types (API keys vs OAuth plans). - ---- - -## Frame - -### Source - -> how should we handle the session feature with opencode? with codex it is a single provider. with opencode users could be using api or various different providers. - -Screenshot shows "Session · Resets 2 hours" and "Weekly · Resets 4 days" usage meters at the bottom of the UI. - -### Problem - -Codex assumes a single provider (OpenAI) with a fixed rate limit structure (session + weekly windows). OpenCode supports multiple providers (Anthropic, OpenAI, OpenRouter, etc.) with different billing models: - -1. **OAuth-based plans** (e.g., Anthropic Max, OpenAI ChatGPT) — have rate limit windows (session/weekly) -2. **API keys** — pay-per-use, no rate limits, just credit balance -3. **Self-hosted** — no limits at all - -The current UI shows "Session" and "Weekly" meters assuming everyone has time-based rate limits. This is incorrect for API key users and multi-provider setups. - -### Outcome - -Usage display that accurately reflects the user's actual billing model per provider. API key users see spend/credits. OAuth users see rate limits. Multi-provider users see usage for their active provider. - ---- - -## Requirements (R) - -| ID | Requirement | Status | -|----|-------------|--------| -| R0 | Show usage information relevant to the user's current billing model | Core goal | -| R1 | API key users see credit balance or spend, not rate limit windows | Must-have | -| R2 | OAuth/plan users see rate limit windows (session/weekly) when available | Must-have | -| R3 | Usage display updates when user switches providers or models | Must-have | -| R4 | No misleading UI — don't show "Session: 0%" to API key users | Must-have | -| R5 | Support providers that don't expose usage data at all (graceful degradation) | Must-have | -| R6 | Usage is scoped to the active provider, not aggregated across all providers | Undecided | -| R7 | Token usage per-turn/per-thread still works regardless of billing model | Must-have | -| R8 | Local usage tracking (JSONL scanning) continues to work for historical view | Nice-to-have | - ---- - -## Shapes - -### CURRENT: Single-provider rate limits - -| Part | Mechanism | -|------|-----------| -| **CUR1** | `RateLimitSnapshot` has `primary` (session) and `secondary` (weekly) windows | -| **CUR2** | `usageLabels.ts` computes percentages assuming both windows exist | -| **CUR3** | UI always shows "Session" and "Weekly" meters | -| **CUR4** | `account/rateLimits/updated` event pushes updates | -| **CUR5** | `AccountSnapshot.type` distinguishes `chatgpt` vs `apikey` but UI treats them the same | -| **CUR6** | `CreditsSnapshot` exists but is secondary to rate limits | - -### A: Provider-aware usage display - -The usage display adapts based on the active provider's billing model. Rate limit windows shown for OAuth plans, credit balance for API keys, nothing for self-hosted. - -| Part | Mechanism | -|------|-----------| -| **A1** | `AccountSnapshot` gains `billingModel: "rate_limited" | "pay_per_use" | "unlimited"` derived from provider auth method | -| **A2** | `usageLabels.ts` returns different label shapes based on billing model | -| **A3** | UI conditionally renders rate limit meters OR credit balance OR nothing | -| **A4** | When user switches provider/model, re-fetch usage info for new provider | -| **A5** | `RateLimitSnapshot` becomes optional — null for pay-per-use and unlimited | -| **A6** | `CreditsSnapshot` expanded to show balance, spend-this-session, and cost-per-token hints | - -### B: Unified usage abstraction - -Abstract all billing models into a single "usage" concept that the UI displays uniformly. - -| Part | Mechanism | -|------|-----------| -| **B1** | Normalize all billing models into `UsageSnapshot { percent?: number, label: string, sublabel?: string }` | -| **B2** | Rate limits → percent; API credits → "X credits remaining"; Unlimited → "Unlimited" | -| **B3** | Single meter component that displays whatever the backend provides | -| **B4** | Backend computes the normalized snapshot, frontend just renders | - ---- - -## Open Questions - -| # | Question | Status | -|---|----------|--------| -| Q1 | Does OpenCode's REST API expose rate limits and/or credit balance per provider? | Needs spike | -| Q2 | Which providers have rate limits vs pay-per-use vs neither? | Needs spike | -| Q3 | Should we show usage for all connected providers or just the active one? | Undecided | -| Q4 | How does OpenCode report usage for API key providers? | Needs spike | - ---- - -## Spike: OpenCode Usage API - -### Context - -We need to understand what usage information OpenCode exposes per provider before we can design the UI. - -### Questions and Answers - -| # | Question | Answer | -|---|----------|--------| -| **S1-Q1** | What does `GET /provider` return? Does it include rate limits or credit info? | No. Returns `{ all: Provider[], default: {...}, connected: string[] }`. Provider includes models and auth methods but no usage/rate data. | -| **S1-Q2** | What does `AccountSnapshot` look like in OpenCode's type system? | Not exposed. The OpenCode REST API has no account/usage endpoint. | -| **S1-Q3** | Is there a per-provider or per-auth-method usage endpoint? | No. The OpenCode server API has no rate limit, credits, or billing endpoints. | -| **S1-Q4** | What events does OpenCode emit for usage updates? | Only per-message token counts via `message.updated` events. No rate limit or account-level usage events. | - -### Finding - -**OpenCode's REST API does not expose rate limit or credit information.** This is fundamentally different from Codex, which had account-level rate limit data. - -What OpenCode DOES provide: -- Per-message token usage (`message.updated` → `tokens: { input, output, cache, reasoning }`) -- Provider/model list with auth method types (`GET /provider/auth`) -- Local session logs that can be scanned for historical token usage - -What OpenCode does NOT provide: -- Rate limit windows (session/weekly) -- Credit balances -- Account billing status -- Provider-specific usage quotas - -### Implication - -The "Session: X%" / "Weekly: X%" UI as designed for Codex **cannot be implemented with OpenCode** because the underlying data doesn't exist. We need a different approach. - ---- - -## Revised Requirements - -Based on spike findings, requirements need updating: - -| ID | Requirement | Status | -|----|-------------|--------| -| R0 | Show usage information relevant to the user's current session | Core goal | -| R1 | ~~API key users see credit balance~~ **Dropped** — OpenCode doesn't expose this | Out | -| R2 | ~~OAuth/plan users see rate limit windows~~ **Dropped** — OpenCode doesn't expose this | Out | -| R3 | 🟡 Usage display reflects token consumption this session | Must-have | -| R4 | No misleading UI — don't show rate limit meters that have no backing data | Must-have | -| R5 | Support providers that don't expose usage data (graceful degradation) | Must-have | -| R6 | 🟡 Per-thread token usage continues to work (already implemented) | Must-have | -| R7 | 🟡 Local usage tracking shows historical token consumption | Nice-to-have | -| R8 | 🟡 Model context window shown relative to current token usage | Nice-to-have | - ---- - -## Revised Shapes - -### C: Token-based usage display (no rate limits) - -Since OpenCode doesn't expose rate limits, replace the Session/Weekly meters with token-based metrics derived from what we actually have. - -| Part | Mechanism | -|------|-----------| -| **C1** | Remove `RateLimitSnapshot` fetching — it's always empty | -| **C2** | Primary metric: tokens used this thread (already have via `thread/tokenUsage/updated`) | -| **C3** | Secondary metric: context window usage percent (tokens / model.limit.context) | -| **C4** | Show "X / Y tokens" or "X% context" instead of "Session: X%" | -| **C5** | Remove "Resets in X hours" — no reset concept for API usage | -| **C6** | Local usage view shows historical token consumption (existing `LocalUsageSnapshot`) | - -### D: Hybrid — preserve UI if provider exposes limits - -If OpenCode adds rate limit support in the future (or if we detect specific providers that have it), conditionally show the old UI. - -| Part | Mechanism | Flag | -|------|-----------|:----:| -| **D1** | Attempt to fetch rate limits via hypothetical endpoint | ⚠️ | -| **D2** | If rate limits exist, show Session/Weekly meters (CURRENT behavior) | | -| **D3** | If no rate limits, fall back to Shape C token display | | -| **D4** | Provider detection: check auth method type to predict billing model | ⚠️ | - ---- - -## Fit Check - -| Req | Requirement | Status | CURRENT | C | D | -|-----|-------------|--------|:-------:|:-:|:-:| -| R0 | Show usage information relevant to the user's current session | Core goal | ❌ | ✅ | ✅ | -| R3 | Usage display reflects token consumption this session | Must-have | ✅ | ✅ | ✅ | -| R4 | No misleading UI — don't show empty rate limit meters | Must-have | ❌ | ✅ | ✅ | -| R5 | Support providers with no usage data (graceful degradation) | Must-have | ❌ | ✅ | ✅ | -| R6 | Per-thread token usage continues to work | Must-have | ✅ | ✅ | ✅ | -| R7 | Local usage tracking shows historical token consumption | Nice-to-have | ✅ | ✅ | ✅ | -| R8 | Model context window shown relative to current token usage | Nice-to-have | ❌ | ✅ | ✅ | - -**Notes:** -- CURRENT fails R0/R4: Shows "Session: 0%" and "Weekly: 0%" with "Resets X hours" which is misleading — there's no rate limit data backing it -- D has flagged unknowns (D1, D4) — depends on OpenCode adding rate limit endpoints in the future -- C is fully implementable with current OpenCode capabilities - -**Recommendation:** Shape C is the pragmatic choice. It uses data we actually have and provides meaningful usage feedback. - ---- - -## Shape C Detail - -### Reference: OpenCode TUI - -Screenshot shows OpenCode's native UI displays: -``` -Context -68,107 tokens -17% used -$0.00 spent -``` - -### What our UI would show - -**Current (misleading):** -``` -Session · Resets 2 hours 0% -Weekly · Resets 4 days 14% -``` - -**Proposed (Shape C) — match OpenCode UI:** -``` -Context -68,107 tokens · 17% used -``` - -Or compact version for status bar: -``` -68.1k tokens · 17% -``` - -The "$0.00 spent" line is omitted — OpenCode calculates this client-side from token counts × model pricing, but this pricing data isn't exposed via REST API. - -### Parts Breakdown - -| Part | Mechanism | -|------|-----------| -| **C1** | `usageLabels.ts` → `getContextUsageLabels()` takes `ThreadTokenUsage` | -| **C2** | Compute `contextPercent = (total.totalTokens / modelContextWindow) * 100` | -| **C3** | Format tokens with locale separator: `68,107 tokens` | -| **C4** | Show percent used when `modelContextWindow` is known | -| **C5** | Remove rate limit meters, replace with context display | -| **C6** | Keep `LocalUsageSnapshot` for historical view | - -### Migration Path - -1. Remove `RateLimitSnapshot` from thread state -2. Remove `account_rate_limits_core` stub and related event handling -3. Update `ComposerMetaBar` to show context usage instead of rate limits -4. Add context window percentage computation -5. Update `usageLabels.ts` with new formatting functions - ---- - -## Open Questions - -| # | Question | Status | -|---|----------|--------| -| Q1 | Should we show cumulative tokens across all threads or just active thread? | **Active thread only** — matches OpenCode TUI | -| Q2 | How prominent should context window usage be? | **Primary display** — matches OpenCode TUI | -| Q3 | Should we surface local historical usage (last 7 days) in the status bar? | **No** — keep footer simple, historical view accessible elsewhere | - ---- - -## Slices - -| # | Slice | Demo | Status | -|---|-------|------|--------| -| V1 | Remove SidebarFooter usage display, add token tooltip to ComposerMetaBar | Hover context ring → "68,107 of 200,000 tokens" | **Done** | -| V2 | Remove rate limit infrastructure | N/A | **Skipped** | - -### V1: Final Implementation - -**Approach changed:** Instead of replacing the sidebar footer display, we removed it entirely. Context info is already displayed in the `ComposerMetaBar` ("Context free 30%"). Added token count tooltip on hover. - -**Changes:** - -| File | Change | -|------|--------| -| `src/features/app/components/Sidebar.tsx` | Removed SidebarFooter, removed `activeTokenUsage` prop | -| `src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx` | Removed `activeTokenUsage` prop | -| `src/features/layout/hooks/layoutNodes/types.ts` | Moved `activeTokenUsage` to keep single definition | -| `src/features/composer/components/ComposerMetaBar.tsx` | Added `contextTooltip` showing token counts on hover | -| `src/App.tsx` | Cleaned up unused rate limit references | -| `src/features/app/components/SidebarFooter.tsx` | Reverted to original (unused, kept for upstream compat) | -| `src/features/app/utils/usageLabels.ts` | Reverted to original (kept for upstream compat) | - -**UI Result:** -- Sidebar footer: **removed** (no misleading rate limit display) -- ComposerMetaBar: Shows "Context free 30%" with tooltip "68,107 of 200,000 tokens" on hover - -### V2 Decision: Skipped - -**Rationale:** Keep rate limit infrastructure dormant to minimize upstream merge conflicts. - -- Upstream CodexMonitor uses rate limits (OpenAI/Codex has them) -- Removing creates conflicts in reducer, types, hooks across many files -- Dead code costs nothing at runtime -- If OpenCode adds rate limit APIs later, infrastructure is ready - -**Demo:** App still works, no rate limit fetching or state, cleaner codebase. diff --git a/docs/shaping/rest-api-migration.md b/docs/shaping/rest-api-migration.md index d2181ec..d69f959 100644 --- a/docs/shaping/rest-api-migration.md +++ b/docs/shaping/rest-api-migration.md @@ -10,8 +10,12 @@ All OpenCode-to-CodexMonitor protocol translation remains in Rust so the fronten ## Current Backend Shape - One shared `opencode serve` process +- Managed server binds explicitly to `127.0.0.1` +- Managed server prefers the monitor port and falls back to a free localhost port on conflict - Workspace scoping via `?directory=` on REST requests - One SSE subscription on `/global/event` +- REST and SSE requests honor `OPENCODE_SERVER_PASSWORD` / `OPENCODE_SERVER_USERNAME` when present +- Managed server health is re-checked before reuse and restarted in place if the child died - Rust translation layer maps OpenCode SSE events into CodexMonitor frontend event shapes - Frontend remains transport-agnostic diff --git a/docs/shaping/upstream-cherry-pick-v057.md b/docs/shaping/upstream-cherry-pick-v057.md deleted file mode 100644 index 597b76c..0000000 --- a/docs/shaping/upstream-cherry-pick-v057.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -shaping: true ---- - -# Upstream Cherry-Pick Batch (v0.7.50 → v0.7.57) — Shaping - -## Frame - -**Source**: CodexMonitor releases v0.7.50 → v0.7.57 - -**Problem**: Our fork is missing useful upstream improvements: build transparency, thread stability, and composer UX. - -**Outcome**: Users see build metadata, threads don't disappear during refresh, and users can quote messages into the composer. - ---- - -## Requirements (R) - -| ID | Requirement | Status | -|----|-------------|--------| -| **R0** | Users can identify exact build version, commit, and date for debugging/support | Core goal | -| **R1** | Active/processing threads aren't lost during partial list refresh | Core goal | -| **R2** | Parent thread chains preserved when child thread is visible | Core goal | -| **R3** | Users can quote assistant messages into the composer | Core goal | -| **R4** | Changes integrate cleanly without breaking existing functionality | Must-have | -| **R5** | Minimal deviation from upstream to ease future merges | Nice-to-have | - ---- - -## Shape A: Port Upstream with Adaptation - -Since upstream has working implementations, the shape is to adapt their code to our codebase. - -| Part | Mechanism | Flag | -|------|-----------|:----:| -| **A1** | **Build Metadata** | | -| A1.1 | Add git info extraction to `vite.config.ts` (execSync for commit/branch/date) | | -| A1.2 | Declare `__APP_COMMIT_HASH__`, `__APP_BUILD_DATE__`, `__APP_GIT_BRANCH__` in `vite-env.d.ts` | | -| A1.3 | Add `SettingsAboutSection.tsx` displaying metadata | | -| A1.4 | Add "About" to settings navigation and section containers | | -| A1.5 | Update existing `AboutView.tsx` to include metadata or redirect to settings | | -| **A2** | **Thread Anchor Preservation** | | -| A2.1 | Extend `setThreads` case in `threadLifecycleSlice.ts` with reconciliation logic | | -| A2.2 | Preserve active thread if missing from incoming list | | -| A2.3 | Preserve threads with `isProcessing` status | | -| A2.4 | Walk `threadParentById` to preserve parent chain | | -| A2.5 | Freshen `updatedAt` using `lastAgentMessageByThread` and `processingStartedAt` | | -| **A3** | **Quote Action** | | -| A3.1 | Add `onQuoteMessage` prop to `Messages` component | | -| A3.2 | Add `toMarkdownQuote()` helper (prefix lines with `> `) | | -| A3.3 | Add `handleQuoteMessage` callback in Messages | | -| A3.4 | Wire quote button in `MessageRows.tsx` for assistant messages | | -| A3.5 | Connect via `buildPrimaryNodes` using existing `onInsertComposerText` | | -| A3.6 | Add quote button styles to `messages.css` | | - ---- - -## Fit Check: R × A - -| Req | Requirement | Status | A | -|-----|-------------|--------|---| -| R0 | Users can identify exact build version, commit, and date for debugging/support | Core goal | ✅ | -| R1 | Active/processing threads aren't lost during partial list refresh | Core goal | ✅ | -| R2 | Parent thread chains preserved when child thread is visible | Core goal | ✅ | -| R3 | Users can quote assistant messages into the composer | Core goal | ✅ | -| R4 | Changes integrate cleanly without breaking existing functionality | Must-have | ✅ | -| R5 | Minimal deviation from upstream to ease future merges | Nice-to-have | ✅ | - -**Notes:** -- R4: All mechanisms use existing patterns (Vite define, reducer extension, existing `onInsertComposerText`) -- R5: Code will closely match upstream with minor naming adjustments - ---- - -## Slices - -Each slice is independently deployable and demo-able. - -### V1: Build Metadata in About - -**Demo**: Open Settings → About, see version/commit/branch/date - -| Affordance | Type | Place | -|------------|------|-------| -| Version display | UI | SettingsAboutSection | -| Commit hash display | UI | SettingsAboutSection | -| Branch display | UI | SettingsAboutSection | -| Build date display | UI | SettingsAboutSection | -| Git info extraction | Non-UI | vite.config.ts | -| Type declarations | Non-UI | vite-env.d.ts | - -**Files**: -- `vite.config.ts` — add git exec + define -- `src/vite-env.d.ts` — declare globals -- `src/features/settings/components/sections/SettingsAboutSection.tsx` — new file -- `src/features/settings/components/sections/SettingsSectionContainers.tsx` — add case -- `src/features/settings/components/SettingsNav.tsx` — add nav item -- `src/features/settings/components/settingsTypes.ts` — add "about" to CodexSection -- `src/features/settings/components/settingsViewConstants.ts` — add label - -### V2: Thread Anchor Preservation - -**Demo**: Start a thread, trigger list refresh while processing, thread stays visible - -| Affordance | Type | Place | -|------------|------|-------| -| Thread reconciliation | Non-UI | threadLifecycleSlice | -| Active thread preservation | Non-UI | threadLifecycleSlice | -| Processing thread preservation | Non-UI | threadLifecycleSlice | -| Parent chain walking | Non-UI | threadLifecycleSlice | - -**Files**: -- `src/features/threads/hooks/threadReducer/threadLifecycleSlice.ts` — extend setThreads case - -### V3: Quote Action for Composer - -**Demo**: Click quote button on assistant message → text appears in composer as blockquote - -| Affordance | Type | Place | -|------------|------|-------| -| Quote button | UI | MessageRows | -| Markdown quote formatter | Non-UI | Messages | -| Quote handler | Non-UI | Messages | -| Composer text insertion | Non-UI | buildPrimaryNodes (existing) | - -**Files**: -- `src/features/messages/components/Messages.tsx` — add prop, helper, handler -- `src/features/messages/components/MessageRows.tsx` — add quote button -- `src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx` — wire onQuoteMessage -- `src/styles/messages.css` — quote button styles - ---- - -## Open Questions - -| # | Question | Impact | -|---|----------|--------| -| Q1 | Should we keep the existing standalone AboutView or redirect it to Settings? | Low — can decide during V1 | -| Q2 | Should quote button be always visible or appear on hover? | Low — follow upstream pattern | - ---- - -## Validation - -Each slice validated with: -- `npm run typecheck` -- `npm run test` (for slices touching tested code) -- Manual testing of the demo scenario - ---- - -## Upstream References - -| Item | Upstream Commit | Upstream PR | -|------|-----------------|-------------| -| Build metadata | 40f6fcb | #490 | -| Thread anchor fix | c0f144b | #494 | -| Quote action | 5e656d5 | #504 | diff --git a/docs/styles.css b/docs/styles.css deleted file mode 100644 index 3a49655..0000000 --- a/docs/styles.css +++ /dev/null @@ -1,764 +0,0 @@ -:root { - color-scheme: dark; - --bg: #080b14; - --bg-2: #101629; - --ink: #e8edf5; - --muted: #b2bccf; - --line: rgba(255, 255, 255, 0.08); - --glass: rgba(15, 20, 35, 0.6); - --accent: #5dd1c6; - --accent-2: #f7b267; - --accent-3: #6ca2ff; - --shadow: 0 24px 70px rgba(5, 10, 25, 0.5); -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: "DM Sans", system-ui, sans-serif; - background: radial-gradient(circle at top, #141b2f 0%, #080b14 55%, #06080f 100%); - color: var(--ink); - min-height: 100vh; - line-height: 1.6; - overflow-x: hidden; -} - -a { - color: inherit; - text-decoration: none; -} - -img { - display: block; - max-width: 100%; -} - -.container { - width: min(1100px, 92vw); - margin: 0 auto; -} - -.site-header { - position: sticky; - top: 0; - z-index: 10; - backdrop-filter: blur(16px); - background: rgba(8, 11, 20, 0.7); - border-bottom: 1px solid var(--line); -} - -.nav { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 0; - gap: 24px; -} - -.logo { - display: inline-flex; - align-items: center; - font-family: "Space Grotesk", system-ui, sans-serif; - font-weight: 600; - letter-spacing: 0.02em; - gap: 10px; -} - -.logo-mark { - width: 22px; - height: 22px; - border-radius: 6px; - object-fit: cover; - box-shadow: 0 0 18px rgba(93, 209, 198, 0.4); -} - -.nav-links { - display: flex; - gap: 18px; - font-size: 0.95rem; - color: var(--muted); -} - -.nav-links a:hover { - color: var(--ink); -} - -.nav-actions { - display: flex; - gap: 12px; -} - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 10px 18px; - border-radius: 999px; - border: 1px solid transparent; - font-weight: 600; - font-size: 0.95rem; - transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease; -} - -.btn.primary { - background: linear-gradient(130deg, var(--accent), var(--accent-3)); - color: #041018; - box-shadow: 0 16px 40px rgba(93, 209, 198, 0.3); -} - -.btn.primary:hover { - transform: translateY(-2px); -} - -.btn.ghost { - border-color: var(--line); - color: var(--muted); -} - -.btn.ghost:hover { - border-color: rgba(255, 255, 255, 0.3); - color: var(--ink); -} - -.hero { - padding: 90px 0 70px; -} - -.hero-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 48px; - align-items: center; -} - -.hero-copy h1 { - font-family: "Space Grotesk", system-ui, sans-serif; - font-size: clamp(2.4rem, 4vw, 3.6rem); - line-height: 1.1; - margin-bottom: 20px; -} - -.hero-copy p { - color: var(--muted); - font-size: 1.05rem; - margin-bottom: 28px; -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.28em; - font-size: 0.7rem; - color: var(--accent); - margin-bottom: 16px; -} - -.hero-actions { - display: flex; - gap: 14px; - margin-bottom: 24px; - flex-wrap: wrap; -} - -.hero-meta { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 14px; - padding-top: 18px; - border-top: 1px solid var(--line); -} - -.meta-label { - display: block; - font-size: 0.8rem; - color: var(--muted); -} - -.meta-value { - font-weight: 600; - font-family: "Space Grotesk", system-ui, sans-serif; -} - -.glass { - background: var(--glass); - border: 1px solid var(--line); - border-radius: 22px; - box-shadow: var(--shadow); - backdrop-filter: blur(22px); -} - -.hero-shot { - padding: 0; - align-self: stretch; - position: relative; -} - -.hero-shot img { - border-radius: 18px; - width: 100%; - height: auto; - box-shadow: 0 24px 70px rgba(6, 12, 26, 0.55); -} - -.hero-shot::before { - content: ""; - position: absolute; - inset: -12px; - border-radius: 28px; - background: radial-gradient(circle at 30% 20%, rgba(108, 162, 255, 0.25), transparent 55%); - opacity: 0.7; - filter: blur(14px); - z-index: -1; -} - -.shot-caption { - font-size: 0.85rem; - color: var(--muted); - margin-top: 12px; -} - -.section { - padding: 70px 0; -} - -.section-title { - max-width: 640px; - margin-bottom: 36px; -} - -.section-title h2 { - font-family: "Space Grotesk", system-ui, sans-serif; - font-size: clamp(1.8rem, 3vw, 2.6rem); - margin-bottom: 12px; -} - -.section-title p { - color: var(--muted); -} - -.feature-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 18px; -} - -.card { - padding: 18px 20px; - display: grid; - gap: 10px; -} - -.card h3 { - font-family: "Space Grotesk", system-ui, sans-serif; - font-size: 1.05rem; -} - -.card p { - color: var(--muted); - font-size: 0.95rem; -} - -.bento-grid { - display: grid; - grid-template-columns: repeat(12, 1fr); - gap: 18px; -} - -.bento-tile { - padding: 18px; - display: grid; - gap: 14px; - min-height: 200px; - overflow: hidden; - transition: transform 0.25s ease, box-shadow 0.25s ease, border 0.25s ease; -} - -.bento-tile:hover { - transform: translateY(-6px); - border-color: rgba(255, 255, 255, 0.2); - box-shadow: 0 28px 60px rgba(6, 12, 26, 0.6); -} - -.bento-media { - border-radius: 16px; - background: rgba(8, 12, 22, 0.65); - padding: 10px; - display: grid; - place-items: center; - overflow: hidden; -} - -.bento-media img { - border-radius: 12px; - width: 100%; - height: 100%; - object-fit: cover; -} - -.bento-title { - display: block; - font-family: "Space Grotesk", system-ui, sans-serif; - font-weight: 600; - font-size: 1rem; -} - -.bento-copy { - display: block; - color: var(--muted); - font-size: 0.9rem; -} - -.bento-pill { - justify-self: start; - padding: 6px 12px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.16); - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--accent); - background: rgba(10, 18, 32, 0.7); -} - -.bento-main { - grid-column: span 7; - min-height: 320px; -} - -.bento-main .bento-media { - aspect-ratio: 16 / 9; -} - -.bento-chat .bento-media { - aspect-ratio: 4 / 3; -} - -.bento-projects .bento-media, -.bento-git .bento-media, -.bento-files .bento-media { - aspect-ratio: 5 / 4; -} - -.bento-chat { - grid-column: span 5; -} - -.bento-projects { - grid-column: span 4; -} - -.bento-git { - grid-column: span 4; -} - -.bento-files { - grid-column: span 4; -} - -.bento-diff { - grid-column: span 8; - min-height: 320px; -} - -.bento-diff .bento-media { - aspect-ratio: 21 / 9; -} - -.bento-skills { - grid-column: span 4; -} - -.bento-skills .bento-media { - aspect-ratio: 4 / 3; -} - -.bento-future, -.bento-future-alt { - grid-column: span 6; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - min-height: 160px; - background: linear-gradient(135deg, rgba(15, 25, 45, 0.7), rgba(10, 16, 30, 0.5)); -} - -.bento-future-alt { - background: linear-gradient(135deg, rgba(10, 20, 38, 0.7), rgba(18, 12, 28, 0.6)); -} - -.workflow { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 18px; -} - -.step { - font-family: "Space Grotesk", system-ui, sans-serif; - font-size: 0.8rem; - color: var(--accent-2); - letter-spacing: 0.22em; -} - -.testimonies-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; -} - -.testimony-card { - padding: 22px; - display: grid; - gap: 16px; -} - -.testimony-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.testimony-person { - display: flex; - align-items: center; - gap: 12px; -} - -.testimony-avatar { - width: 46px; - height: 46px; - border-radius: 999px; - overflow: hidden; - border: 1px solid var(--line); - box-shadow: 0 10px 24px rgba(6, 12, 26, 0.45); -} - -.testimony-avatar img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.testimony-meta { - display: grid; - line-height: 1.2; -} - -.testimony-name { - font-family: "Space Grotesk", system-ui, sans-serif; - font-weight: 600; -} - -.testimony-handle { - color: var(--muted); - font-size: 0.92rem; -} - -.testimony-handle:hover { - color: var(--ink); -} - -.testimony-view { - font-size: 0.85rem; - color: var(--muted); - border: 1px solid var(--line); - padding: 6px 10px; - border-radius: 999px; - white-space: nowrap; -} - -.testimony-view:hover { - color: var(--ink); - border-color: rgba(255, 255, 255, 0.25); -} - -.testimony-quote { - color: var(--ink); - font-size: 1.02rem; -} - -@media (max-width: 900px) { - .testimonies-grid { - grid-template-columns: 1fr; - } -} - -.cta { - padding: 36px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - flex-wrap: wrap; -} - -.cta h2 { - font-family: "Space Grotesk", system-ui, sans-serif; - font-size: clamp(1.8rem, 3vw, 2.4rem); - margin-bottom: 10px; -} - -.cta p { - color: var(--muted); -} - -.cta-actions { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.site-footer { - padding: 40px 0 60px; - border-top: 1px solid var(--line); -} - -.footer-grid { - display: flex; - justify-content: space-between; - gap: 32px; - flex-wrap: wrap; -} - -.footer-links { - display: grid; - gap: 10px; - color: var(--muted); -} - -.footer-links a:hover { - color: var(--ink); -} - -.footer-meta { - margin-top: 24px; - color: var(--muted); - font-size: 0.9rem; - display: grid; - gap: 6px; -} - -.footer-meta p { - margin: 0; -} - -.footer-meta a { - color: var(--ink); -} - -.changelog-hero { - padding-bottom: 20px; -} - -.section-tight { - padding-top: 40px; -} - -.release-status { - color: var(--muted); - font-size: 1rem; - padding: 18px 0 10px; -} - -.release-error { - color: var(--accent-2); -} - -.changelog-list { - display: grid; - gap: 20px; -} - -.release-card { - padding: 24px; - display: grid; - gap: 16px; -} - -.release-header { - display: grid; - gap: 8px; -} - -.release-title { - font-family: "Space Grotesk", system-ui, sans-serif; - font-size: 1.4rem; -} - -.release-meta { - display: flex; - gap: 12px; - color: var(--muted); - font-size: 0.9rem; - flex-wrap: wrap; -} - -.release-tag { - font-weight: 600; - color: var(--accent); -} - -.release-body { - display: grid; - gap: 12px; - color: var(--muted); -} - -.release-body h4 { - color: var(--ink); - font-size: 1rem; - font-family: "Space Grotesk", system-ui, sans-serif; -} - -.release-body p { - font-size: 0.95rem; -} - -.release-body a { - color: var(--ink); - border-bottom: 1px solid rgba(255, 255, 255, 0.2); -} - -.release-body code { - background: rgba(8, 12, 22, 0.6); - padding: 0 6px; - border-radius: 6px; - font-size: 0.9rem; -} - -.release-list { - display: grid; - gap: 8px; - padding-left: 18px; - color: var(--muted); -} - -.release-empty { - color: var(--muted); - font-style: italic; -} - -.bg-orb { - position: fixed; - border-radius: 50%; - filter: blur(50px); - opacity: 0.5; - z-index: -1; - animation: float 14s ease-in-out infinite; -} - -.orb-1 { - width: 420px; - height: 420px; - background: radial-gradient(circle, rgba(93, 209, 198, 0.45), transparent 70%); - top: -160px; - left: -80px; -} - -.orb-2 { - width: 520px; - height: 520px; - background: radial-gradient(circle, rgba(108, 162, 255, 0.35), transparent 70%); - bottom: -220px; - right: -120px; - animation-delay: -6s; -} - -.orb-3 { - width: 360px; - height: 360px; - background: radial-gradient(circle, rgba(247, 178, 103, 0.25), transparent 70%); - top: 40%; - right: 10%; - animation-delay: -3s; -} - -@keyframes float { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(30px); - } -} - -@media (max-width: 900px) { - .nav-links { - display: none; - } - - .hero { - padding-top: 70px; - } -} - -@media (min-width: 980px) { - .hero-grid { - grid-template-columns: 1fr 1.3fr; - } -} - -@media (max-width: 720px) { - .nav { - flex-wrap: wrap; - } - - .hero-actions, - .cta-actions { - width: 100%; - } - - .btn { - width: 100%; - } - - .bento-grid { - grid-template-columns: repeat(2, 1fr); - } - - .bento-main, - .bento-chat, - .bento-projects, - .bento-git, - .bento-files, - .bento-diff, - .bento-skills, - .bento-future, - .bento-future-alt { - grid-column: span 2; - } -} - -@media (max-width: 520px) { - .bento-grid { - grid-template-columns: 1fr; - } - - .bento-main, - .bento-chat, - .bento-projects, - .bento-git, - .bento-files, - .bento-diff, - .bento-skills, - .bento-future, - .bento-future-alt { - grid-column: span 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .bg-orb { - animation: none; - } - - .btn { - transition: none; - } -} diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs index ea5f029..8a8097d 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -6,7 +6,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use base64::Engine; use futures_util::StreamExt; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; use reqwest_eventsource::{Event, EventSource}; use tokio::process::{Child, Command}; use tokio::sync::{mpsc, watch, Mutex, OnceCell}; @@ -57,6 +59,66 @@ fn rest_base_url() -> String { format!("http://127.0.0.1:{REST_PORT}") } +fn rest_base_url_for_port(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +fn server_auth_header_value() -> Option { + let password = env::var("OPENCODE_SERVER_PASSWORD").ok()?; + let password = password.trim(); + if password.is_empty() { + return None; + } + + let username = env::var("OPENCODE_SERVER_USERNAME") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "opencode".to_string()); + + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + HeaderValue::from_str(&format!("Basic {encoded}")).ok() +} + +fn server_default_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(auth) = server_auth_header_value() { + headers.insert(AUTHORIZATION, auth); + } + headers +} + +fn server_http_client(timeout: Option) -> Result { + let mut builder = reqwest::Client::builder().default_headers(server_default_headers()); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + builder.build().map_err(|e| e.to_string()) +} + +fn preferred_managed_rest_port() -> Result { + if std::net::TcpListener::bind(("127.0.0.1", REST_PORT)).is_ok() { + return Ok(REST_PORT); + } + + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| e.to_string())?; + listener + .local_addr() + .map(|addr| addr.port()) + .map_err(|e| e.to_string()) +} + +async fn tracked_server_base_url() -> Option { + if let Some(server_mutex) = SERVER_PROCESS.get() { + return Some(server_mutex.lock().await.base_url.clone()); + } + + read_pid_file() + .await + .map(|pid_data| rest_base_url_for_port(pid_data.port)) +} + // --------------------------------------------------------------------------- // PID file management for server ownership tracking // --------------------------------------------------------------------------- @@ -183,7 +245,7 @@ async fn try_reclaim_orphaned_server() -> bool { } // Process is running - check if it's actually our server on the expected port - let base_url = rest_base_url(); + let base_url = rest_base_url_for_port(pid_data.port); if health_check(&base_url).await.is_err() { // Process exists but isn't responding as our server - stale PID file delete_pid_file().await; @@ -331,7 +393,9 @@ pub(crate) async fn opencode_restart_required_status() -> Value { }); }; - let base_url = rest_base_url(); + let base_url = tracked_server_base_url() + .await + .unwrap_or_else(rest_base_url); let server_healthy = health_check(&base_url).await.is_ok(); let managed = is_server_owned().await; @@ -477,19 +541,23 @@ async fn start_managed_server_process( codex_bin: Option, codex_args: Option<&str>, ) -> Result { - let base_url = rest_base_url(); + let port = preferred_managed_rest_port()?; + let base_url = rest_base_url_for_port(port); let mut command = build_codex_command_with_bin( codex_bin, codex_args, vec![ "serve".to_string(), + "--hostname".to_string(), + "127.0.0.1".to_string(), "--port".to_string(), - REST_PORT.to_string(), + port.to_string(), ], )?; command.stdin(std::process::Stdio::null()); command.stdout(std::process::Stdio::null()); command.stderr(std::process::Stdio::null()); + command.env("OPENCODE_CLIENT", "opencode-monitor"); let child = command.spawn().map_err(|e| { if e.kind() == ErrorKind::NotFound { @@ -502,7 +570,7 @@ async fn start_managed_server_process( // Write PID file for ownership tracking if let Some(pid) = child.id() { - if let Err(e) = write_pid_file(pid, REST_PORT).await { + if let Err(e) = write_pid_file(pid, port).await { eprintln!("Warning: failed to write PID file: {e}"); } } @@ -528,9 +596,20 @@ async fn ensure_server_running( ) -> Result { let base_url = rest_base_url(); - // Fast path: if already initialized, just return the URL. - if SERVER_PROCESS.get().is_some() { - return Ok(base_url); + // Fast path: if we already manage a server, verify it's still healthy and + // replace it in-place if it exited after initialization. + if let Some(server_mutex) = SERVER_PROCESS.get() { + let mut guard = server_mutex.lock().await; + if health_check(&guard.base_url).await.is_ok() { + return Ok(guard.base_url.clone()); + } + + let _ = kill_child_process_tree(&mut guard.child).await; + delete_pid_file().await; + let replacement = start_managed_server_process(codex_bin, codex_args).await?; + let replacement_base_url = replacement.base_url.clone(); + *guard = replacement; + return Ok(replacement_base_url); } // Check if we have an orphaned server we can reclaim (via PID file). @@ -558,10 +637,7 @@ async fn ensure_server_running( } async fn health_check(base_url: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(3)) - .build() - .map_err(|e| e.to_string())?; + let client = server_http_client(Some(Duration::from_secs(3)))?; let resp = client .get(format!("{base_url}/global/health")) .send() @@ -580,10 +656,7 @@ pub(crate) async fn global_rest_get( directory: Option<&str>, ) -> Result { let base_url = ensure_server_running(codex_bin, codex_args).await?; - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(300)) - .build() - .map_err(|e| e.to_string())?; + let client = server_http_client(Some(Duration::from_secs(300)))?; let mut url = format!("{base_url}{path}"); if let Some(directory) = directory.filter(|value| !value.trim().is_empty()) { let separator = if path.contains('?') { "&" } else { "?" }; @@ -608,7 +681,9 @@ async fn is_server_owned() -> bool { } // Check if we have a valid PID file for the running server if let Some(pid_data) = read_pid_file().await { - if is_process_running(pid_data.pid) && pid_data.port == REST_PORT { + if is_process_running(pid_data.pid) + && health_check(&rest_base_url_for_port(pid_data.port)).await.is_ok() + { return true; } } @@ -616,7 +691,7 @@ async fn is_server_owned() -> bool { } pub(crate) async fn opencode_server_status() -> Value { - let base_url = rest_base_url(); + let base_url = tracked_server_base_url().await.unwrap_or_else(rest_base_url); let managed = is_server_owned().await; match health_check(&base_url).await { Ok(health) => json!({ @@ -660,7 +735,7 @@ pub(crate) async fn restart_opencode_server( // Case 2: We have a PID file (reclaimed server) - kill by PID and start fresh // Verify port matches to avoid killing an unrelated process if the PID was reused. if let Some(pid_data) = read_pid_file().await { - if is_process_running(pid_data.pid) && pid_data.port == REST_PORT { + if is_process_running(pid_data.pid) { terminate_process(pid_data.pid, false); tokio::time::sleep(Duration::from_millis(500)).await; if is_process_running(pid_data.pid) { @@ -1135,7 +1210,40 @@ fn spawn_sse_reader( break; } - let mut es = EventSource::get(&url); + let sse_client = match server_http_client(None) { + Ok(client) => client, + Err(error) => { + event_sink.emit_app_server_event(AppServerEvent { + workspace_id: workspace_id.clone(), + message: json!({ + "method": "codex/parseError", + "params": { "error": error, "raw": "failed to create SSE client" }, + }), + }); + sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(max_reconnect_delay); + continue; + } + }; + let request = sse_client.get(&url); + let mut es = match EventSource::new(request) { + Ok(es) => es, + Err(error) => { + event_sink.emit_app_server_event(AppServerEvent { + workspace_id: workspace_id.clone(), + message: json!({ + "method": "codex/parseError", + "params": { + "error": error.to_string(), + "raw": "failed to initialize SSE event source", + }, + }), + }); + sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(max_reconnect_delay); + continue; + } + }; loop { tokio::select! { @@ -1250,10 +1358,7 @@ pub(crate) async fn spawn_workspace_session( // Ensure the shared `opencode serve` process is running. let base_url = ensure_server_running(codex_bin, codex_args.as_deref()).await?; - let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(300)) - .build() - .map_err(|e| e.to_string())?; + let http_client = server_http_client(Some(Duration::from_secs(300)))?; let (shutdown_tx, shutdown_rx) = watch::channel(false);