From 3e4b5358a9c2196150c30db73fd385b22ba0e06b Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 20 Jul 2026 18:47:41 +0800 Subject: [PATCH 1/4] docs(daemon): add design for headless terminal_ready timeout fix In headless mode the readiness signal path (renderer -> screenshot WS -> terminal_ready_sandboxes) never fires, so 'cli-box start' always hits the 60s terminal readiness timeout. Spec proposes the daemon answer readiness from PTY state in headless mode. Co-Authored-By: Claude --- ...-headless-terminal-ready-timeout-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-headless-terminal-ready-timeout-design.md diff --git a/docs/superpowers/specs/2026-07-20-headless-terminal-ready-timeout-design.md b/docs/superpowers/specs/2026-07-20-headless-terminal-ready-timeout-design.md new file mode 100644 index 0000000..da72489 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-headless-terminal-ready-timeout-design.md @@ -0,0 +1,129 @@ +# Headless terminal_ready Timeout — Design + +**Date**: 2026-07-20 +**Scope**: `daemon` +**Branch**: `fix/daemon-headless-terminal-ready` + +## Problem + +On Linux/cloud hosts (the Aliyun headless test env, and any machine without an +Electron binary), `cli-box start` always blocks for the full 60s terminal +readiness timeout before reporting the sandbox ready. The sandbox is actually +usable within milliseconds — the wait is pure waste, and the trailing error +hint ("Terminal not ready within 60s …") is misleading. + +## Root Cause + +The CLI's terminal-readiness poll (`crates/cli-box-cli/src/main.rs:497-540`) +asks `/readyz?sandbox_id=` every 500 ms and only proceeds when the response +carries `terminal_ready == true`. + +`terminal_ready` for a specific sandbox is `true` only when the sandbox id is +present in `DaemonState::terminal_ready_sandboxes` +(`crates/cli-box-core/src/daemon/mod.rs:345-348`). That set is populated by +**exactly one** code path: the Electron renderer sends +`{type:"terminal_ready", sandbox_id}` over the screenshot WebSocket, and the +daemon inserts it (`daemon/mod.rs:1039-1042`). + +In headless mode this path cannot fire: + +1. No Electron binary ⇒ the CLI starts the daemon with `--headless` + (`main.rs:1850-1854`) and `ensure_healthy_electron()` returns immediately + (`main.rs:1923-1928`). No renderer is spawned. +2. With no renderer, the screenshot WebSocket is never opened, so + `terminal_ready_sandboxes` is never populated. +3. The CLI poll therefore always sees `terminal_ready == false` and times out + at exactly 60 s. + +The "固定 60s" symptom is a guaranteed timeout, not slow startup. The PTY and +the server-side `HeadlessTerminal` grid are ready almost immediately — nothing +just signals that fact in headless mode. + +## Solution + +Make the daemon itself answer the readiness question in headless mode, since +there is no renderer to do it. A sandbox is terminal-ready in headless mode as +soon as its PTY exists (PTYs are spawned synchronously inside +`create_sandbox_handler` before the response is returned, so this is race-free). + +### Change: `readyz_handler` (`daemon/mod.rs:339-358`) + +Compute `terminal_ready` based on PTY existence when `state.headless` is set; +leave the non-headless (renderer-driven) path untouched. + +```rust +let terminal_ready = match params.get("sandbox_id") { + None => true, + Some(sandbox_id) => { + if s.headless { + // No renderer in headless mode; the terminal is ready as soon as + // the sandbox's PTY exists (spawned synchronously at creation). + s.sandboxes + .get(sandbox_id.as_str()) + .map(|sb| sb.pty_pid.is_some()) + .unwrap_or(false) + } else { + s.terminal_ready_sandboxes.contains(sandbox_id.as_str()) + } + } +}; +``` + +### Why this shape + +- **`pty_pid.is_some()` mirrors the CLI's own gate.** The CLI only polls when + `result.pty_pid.is_some()` (`main.rs:493`), i.e. for CLI sandboxes. App + sandboxes have `pty_pid: None` and never enter the wait, so returning `false` + for them is harmless and consistent. +- **Unknown sandbox_id ⇒ `false`.** Same as the non-headless path, which also + yields `false` for an unknown id. +- **No protocol change.** No new field on `DaemonReadinessResponse`; the CLI, + MCP, and HTTP clients are unchanged. The `None`-sandbox_id branch keeps + returning `true` (overall daemon readiness). + +## Out of Scope + +- The non-headless readiness path is untouched. +- `ensure_healthy_electron()` already early-returns in headless mode, so its + separate 60s renderer wait is not hit. No change there. +- The CLI's 60s timeout stays as a safety net for the non-headless path; in + headless it returns on the first poll. +- **`status` and `renderer_connected` are left headless-unaware.** In headless + mode `renderer_connected` is `false` (no Electron WS — truthful) and so + `status` stays `"not_ready"` even when `terminal_ready` is `true`. This is + accepted: in the CLI's `DaemonReadinessResponse` both fields are + `#[allow(dead_code)]` — the CLI gates solely on `terminal_ready`, and no other + client reads them. Making `status` consistent is cosmetic and deliberately + deferred (YAGNI). + +## Testing + +**IT (integration)** — `crates/cli-box-core/tests/daemon_integration.rs`, +alongside the existing `readyz_returns_not_ready_without_renderer` test and the +`headless_state_with_sandbox(id, pty_pid)` helper (`#[cfg(unix)]`, line 352). + +Add a test `readyz_terminal_ready_in_headless_mode` that: + +1. Builds the router from `headless_state_with_sandbox("sb-1", 4242)` and sends + `GET /readyz?sandbox_id=sb-1` ⇒ asserts `terminal_ready == true`. (Do **not** + assert `status == "ready"` — see "Out of Scope": `renderer_connected` is + false in headless, so `status` stays `"not_ready"`. The CLI gates solely on + `terminal_ready`, which is what we assert.) +2. Sends `GET /readyz?sandbox_id=unknown` ⇒ asserts `terminal_ready == false`. + +Add `readyz_terminal_ready_uses_renderer_set_when_not_headless` (or extend an +existing test) confirming that with `headless: false`, `terminal_ready` still +follows `terminal_ready_sandboxes` (insert ⇒ true; absent ⇒ false) — guarding +the regression that the headless branch must not affect the GUI path. + +**Manual / E2E** — on the Aliyun headless host, `cli-box start` returns +" Sandbox ready" within ~1s instead of 60s, and the misleading 60s error/hint +is no longer printed. (Captured as a release-test step; not a unit assertion.) + +## Risks + +- **Readiness semantics drift.** Headless readiness now means "PTY spawned" + rather than "xterm.js mounted". These are equivalent in headless mode (no + renderer to mount), so no caller is affected. +- **Future caller expecting renderer-based readiness in headless.** None exist + today; the readiness endpoint remains the single source of truth. From b01b149d50b789a9e151bda39fe2864ce0ceef24 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 20 Jul 2026 18:52:37 +0800 Subject: [PATCH 2/4] docs(daemon): add implementation plan for headless terminal_ready fix Co-Authored-By: Claude --- ...6-07-20-headless-terminal-ready-timeout.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-headless-terminal-ready-timeout.md diff --git a/docs/superpowers/plans/2026-07-20-headless-terminal-ready-timeout.md b/docs/superpowers/plans/2026-07-20-headless-terminal-ready-timeout.md new file mode 100644 index 0000000..ff31a71 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-headless-terminal-ready-timeout.md @@ -0,0 +1,235 @@ +# Headless terminal_ready Timeout Fix — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the fixed 60s terminal-readiness timeout that `cli-box start` hits in headless mode by having the daemon answer readiness from PTY state. + +**Architecture:** Single change in `readyz_handler`: when `DaemonState.headless` is true, compute `terminal_ready` from the queried sandbox's `pty_pid` existence instead of the renderer-populated `terminal_ready_sandboxes` set (which is never populated in headless mode because there is no Electron renderer). The non-headless path is unchanged. + +**Tech Stack:** Rust, axum (`tower::ServiceExt::oneshot` integration tests), tokio. + +## Global Constraints + +- Code and comments in English; user-facing communication in Chinese (CLAUDE.md §七). +- TDD: write the failing test first, watch it fail, then implement. +- `cargo fmt --all -- --check` and `cargo clippy --all-targets -- -D warnings` must be clean before commit. +- Commit format `(): `, scope `daemon`. Implementation + tests in one commit. +- Do not merge to main; commit on the existing branch `fix/daemon-headless-terminal-ready`. + +## File Structure + +- **Modify:** `crates/cli-box-core/src/daemon/mod.rs` — `readyz_handler` (currently lines 339-358). Responsibility: serve the daemon `/readyz` polling endpoint. +- **Test:** `crates/cli-box-core/tests/daemon_integration.rs` — add two test functions and one non-`unix`-gated state helper. Responsibility: exercise daemon routes via `oneshot` without binding a TCP port. + +No new files. The change is intentionally localized to the readiness endpoint and its tests. + +--- + +## Task 1: Headless terminal readiness from PTY state + +**Files:** +- Modify: `crates/cli-box-core/src/daemon/mod.rs` (the `readyz_handler` function, lines ~345-348) +- Test: `crates/cli-box-core/tests/daemon_integration.rs` (add helper + 2 tests, near the existing `readyz_returns_not_ready_without_renderer` test at line 207) + +**Interfaces:** +- Consumes: `DaemonState` fields `headless: bool`, `sandboxes: HashMap`, `terminal_ready_sandboxes: HashSet`; `ManagedSandbox.pty_pid: Option`. All pre-existing, unchanged. +- Produces: no new public API. Only the JSON value of `terminal_ready` in the `/readyz?sandbox_id=` response changes (now `true` in headless mode for sandboxes with a PTY). + +- [ ] **Step 1: Add the failing test + helper** + +Append this helper after the existing `router_with_sandbox` function (after line 64). It is intentionally **not** `#[cfg(unix)]` — `readyz` only inspects state and never spawns a real PTY, so it runs on all platforms (mac dev + Linux CI): + +```rust +/// Headless daemon state with one CLI sandbox carrying `pty_pid`. +/// Not unix-gated: readyz only inspects state, it does not spawn a PTY. +fn headless_ready_state(id: &str, pty_pid: u32) -> Arc> { + let mut sandboxes = HashMap::new(); + sandboxes.insert( + id.to_string(), + ManagedSandbox { + id: id.to_string(), + kind: InstanceKind::Cli { + command: "zsh".to_string(), + args: vec![], + }, + status: InstanceStatus::Running, + port: 0, + pty_pid: Some(pty_pid), + window_id: None, + }, + ); + Arc::new(Mutex::new(DaemonState { + port: 0, + sandboxes, + started_at: std::time::Instant::now(), + screenshot_ws_tx: None, + pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), + screenshot_request_counter: 0, + terminal_ready_sandboxes: HashSet::new(), + headless: true, + })) +} +``` + +Then add this test next to `readyz_returns_not_ready_without_renderer` (after line 224): + +```rust +#[tokio::test] +async fn readyz_terminal_ready_in_headless_mode() { + // Headless mode has no renderer, so terminal_ready must be derived from + // the sandbox's PTY existence rather than the renderer-reported set. + let resp = build_daemon_router(headless_ready_state("sb-1", 4242)) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=sb-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], true); + + // Unknown sandbox id -> not ready. + let resp = build_daemon_router(headless_ready_state("sb-1", 4242)) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=unknown") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], false); +} +``` + +- [ ] **Step 2: Run the new test and verify it FAILS (red)** + +Run: `cargo test -p cli-box-core --test daemon_integration readyz_terminal_ready_in_headless_mode` +Expected: FAIL — `assert_eq!(json["terminal_ready"], true)` fails because the current handler returns `false` (the `terminal_ready_sandboxes` set is empty and nothing populates it in headless mode). + +- [ ] **Step 3: Implement the fix in `readyz_handler`** + +In `crates/cli-box-core/src/daemon/mod.rs`, replace this block inside `readyz_handler` (currently lines 345-348): + +```rust + let terminal_ready = match params.get("sandbox_id") { + Some(sandbox_id) => s.terminal_ready_sandboxes.contains(sandbox_id.as_str()), + None => true, + }; +``` + +with: + +```rust + let terminal_ready = match params.get("sandbox_id") { + None => true, + Some(sandbox_id) => { + if s.headless { + // No renderer in headless mode; the terminal is ready as + // soon as the sandbox's PTY exists (spawned synchronously at + // creation). The CLI only polls for CLI sandboxes, which + // always carry a pty_pid, so this mirrors its own gate. + s.sandboxes + .get(sandbox_id.as_str()) + .map(|sb| sb.pty_pid.is_some()) + .unwrap_or(false) + } else { + s.terminal_ready_sandboxes.contains(sandbox_id.as_str()) + } + } + }; +``` + +- [ ] **Step 4: Run the new test and verify it PASSES (green)** + +Run: `cargo test -p cli-box-core --test daemon_integration readyz_terminal_ready_in_headless_mode` +Expected: PASS. + +- [ ] **Step 5: Add the non-headless regression-guard test** + +Add this test next to the one above. It locks in that the headless branch did not alter the renderer-driven path (`state_with_sandbox` has `headless: false`, `pty_pid: None`, empty ready set): + +```rust +#[tokio::test] +async fn readyz_terminal_ready_uses_renderer_set_when_not_headless() { + // Non-headless: readiness must still come from terminal_ready_sandboxes, + // unaffected by the headless branch. + let state = state_with_sandbox(); + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=test-sb") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], false); + + // Once the renderer reports ready, it flips to true. + { + let mut s = state.lock().await; + s.terminal_ready_sandboxes.insert("test-sb".to_string()); + } + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=test-sb") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], true); +} +``` + +- [ ] **Step 6: Run the full quality gate** + +Run each; all must pass: + +```bash +cargo test -p cli-box-core --test daemon_integration +cargo clippy --all-targets -- -D warnings +cargo fmt --all -- --check +``` + +Expected: all daemon_integration tests pass (including the two new ones); clippy clean; fmt clean. If `fmt --check` reports a diff, run `cargo fmt --all` and re-check. + +- [ ] **Step 7: Commit** + +```bash +git add crates/cli-box-core/src/daemon/mod.rs crates/cli-box-core/tests/daemon_integration.rs +git commit -m "fix(daemon): resolve headless terminal_ready timeout + +In headless mode no renderer connects the screenshot WebSocket, so +terminal_ready_sandboxes was never populated and 'cli-box start' always +hit the 60s readiness timeout. Derive terminal_ready from the sandbox's +PTY existence in headless mode; non-headless path unchanged. + +Co-Authored-By: Claude " +``` + +--- + +## Manual Verification (post-implementation, on the Aliyun headless host) + +After Task 1 lands, confirm end-to-end on `47.98.144.243` (per memory: cli-box source present, no Electron → headless): + +```bash +time cli-box start # default zsh sandbox +``` + +Expected: completes ("Sandbox ready") within ~1s, **not** 60s, and no "Terminal not ready within 60s" error/hint is printed. This is a release-test step (per CLAUDE.md §6.3), not a unit assertion. From 8615cf2ab8e2ddd1b703ec592100aae688c9cc1a Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 20 Jul 2026 18:58:45 +0800 Subject: [PATCH 3/4] fix(daemon): resolve headless terminal_ready timeout In headless mode no renderer connects the screenshot WebSocket, so terminal_ready_sandboxes was never populated and 'cli-box start' always hit the 60s readiness timeout. Derive terminal_ready from the sandbox's PTY existence in headless mode; non-headless path unchanged. Co-Authored-By: Claude --- crates/cli-box-core/src/daemon/mod.rs | 15 ++- .../cli-box-core/tests/daemon_integration.rs | 102 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/cli-box-core/src/daemon/mod.rs b/crates/cli-box-core/src/daemon/mod.rs index e1e9da2..7e16319 100644 --- a/crates/cli-box-core/src/daemon/mod.rs +++ b/crates/cli-box-core/src/daemon/mod.rs @@ -343,8 +343,21 @@ async fn readyz_handler( let s = state.lock().await; let renderer_connected = s.screenshot_ws_tx.is_some(); let terminal_ready = match params.get("sandbox_id") { - Some(sandbox_id) => s.terminal_ready_sandboxes.contains(sandbox_id.as_str()), None => true, + Some(sandbox_id) => { + if s.headless { + // No renderer in headless mode; the terminal is ready as + // soon as the sandbox's PTY exists (spawned synchronously at + // creation). The CLI only polls for CLI sandboxes, which + // always carry a pty_pid, so this mirrors its own gate. + s.sandboxes + .get(sandbox_id.as_str()) + .map(|sb| sb.pty_pid.is_some()) + .unwrap_or(false) + } else { + s.terminal_ready_sandboxes.contains(sandbox_id.as_str()) + } + } }; Json(DaemonReadinessResponse { status: if renderer_connected && terminal_ready { diff --git a/crates/cli-box-core/tests/daemon_integration.rs b/crates/cli-box-core/tests/daemon_integration.rs index 6835cf9..7ca8c80 100644 --- a/crates/cli-box-core/tests/daemon_integration.rs +++ b/crates/cli-box-core/tests/daemon_integration.rs @@ -63,6 +63,37 @@ fn router_with_sandbox() -> axum::Router { build_daemon_router(state_with_sandbox()) } +/// Headless daemon state with one CLI sandbox carrying `pty_pid`. +/// Not unix-gated: readyz only inspects state, it does not spawn a PTY. +fn headless_ready_state(id: &str, pty_pid: u32) -> Arc> { + let mut sandboxes = HashMap::new(); + sandboxes.insert( + id.to_string(), + ManagedSandbox { + id: id.to_string(), + kind: InstanceKind::Cli { + command: "zsh".to_string(), + args: vec![], + }, + status: InstanceStatus::Running, + port: 0, + pty_pid: Some(pty_pid), + window_id: None, + }, + ); + Arc::new(Mutex::new(DaemonState { + port: 0, + sandboxes, + started_at: std::time::Instant::now(), + screenshot_ws_tx: None, + pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), + screenshot_request_counter: 0, + terminal_ready_sandboxes: HashSet::new(), + headless: true, + })) +} + #[tokio::test] async fn health_endpoint_returns_ok() { let resp = router() @@ -223,6 +254,77 @@ async fn readyz_returns_not_ready_without_renderer() { assert_eq!(json["renderer_connected"], false); } +#[tokio::test] +async fn readyz_terminal_ready_in_headless_mode() { + // Headless mode has no renderer, so terminal_ready must be derived from + // the sandbox's PTY existence rather than the renderer-reported set. + let resp = build_daemon_router(headless_ready_state("sb-1", 4242)) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=sb-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], true); + + // Unknown sandbox id -> not ready. + let resp = build_daemon_router(headless_ready_state("sb-1", 4242)) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=unknown") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], false); +} + +#[tokio::test] +async fn readyz_terminal_ready_uses_renderer_set_when_not_headless() { + // Non-headless: readiness must still come from terminal_ready_sandboxes, + // unaffected by the headless branch. + let state = state_with_sandbox(); + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=test-sb") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], false); + + // Once the renderer reports ready, it flips to true. + { + let mut s = state.lock().await; + s.terminal_ready_sandboxes.insert("test-sb".to_string()); + } + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/readyz?sandbox_id=test-sb") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["terminal_ready"], true); +} + #[tokio::test] async fn screenshot_query_parses_scroll_and_top() { let resp = router_with_sandbox() From d8ec07adc395b7bd36363dd7ce64285699a27665 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 20 Jul 2026 23:51:09 +0800 Subject: [PATCH 4/4] fix(cli): correct dev-mode Electron binary name in find_electron_binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev dist paths (dist/electron/mac-arm64 and mac) looked for Contents/MacOS/cli-box, but the built bundle's launcher binary is named "CLI Box" — matching the release path and both cached/downloaded paths, and the actual file on disk. In dev mode this made Electron fail to launch ('No such file or directory'), so no renderer connected and 'cli-box start' hit the 60s terminal_ready timeout on the non-headless path. Align the dev paths with the other four. The legacy Tauri cmd_start (line 340) is a different app and is left unchanged. Co-Authored-By: Claude --- crates/cli-box-cli/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli-box-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index b44fc46..0cf9bee 100644 --- a/crates/cli-box-cli/src/main.rs +++ b/crates/cli-box-cli/src/main.rs @@ -1658,13 +1658,13 @@ fn find_electron_binary() -> Option { let cwd = std::env::current_dir().unwrap_or_default(); let dev_bundle = cwd.join("dist/electron/mac-arm64/CLI Box.app"); if dev_bundle.exists() { - return Some(dev_bundle.join("Contents/MacOS/cli-box")); + return Some(dev_bundle.join("Contents/MacOS/CLI Box")); } // Also check x64 let dev_bundle_x64 = cwd.join("dist/electron/mac/CLI Box.app"); if dev_bundle_x64.exists() { - return Some(dev_bundle_x64.join("Contents/MacOS/cli-box")); + return Some(dev_bundle_x64.join("Contents/MacOS/CLI Box")); } // Auto-download fallback: check ~/.cli-box/bin/CLI Box.app