Full Headless Mode: daemon + thin clients#157
Open
matej21 wants to merge 169 commits into
Open
Conversation
89b6931 to
dcf0ae5
Compare
…UI-free action layer
Add a minimal WorkspaceCx trait (notify + refresh_views) implemented for
gpui::Context<Workspace>, and migrate the Workspace action/state layer from
`&mut Context<Self>` to `&mut impl WorkspaceCx`. This decouples the action
layer from GPUI so a future headless daemon can drive the same execute_action
path with a plain reactor instead of a GPUI context. The protocol/seam already
isolates clients, so swapping the daemon's reactor stays a two-way door.
Converted: focus, terminal, folder, soft_close, layout/{close,split,move_ops,
tabs}, all of state.rs, plus the pure methods in worktree/project.
Stays on Context (needs &App for HookMonitor/HookRunner globals, handled in a
later phase): project::{add_project,delete_project} and the worktree
registration/hook chain. RequestBroker (UI entity) and worktree_sync
(background spawn) are intentionally out of scope.
Non-breaking: existing callers pass `&mut Context<Workspace>`, which satisfies
the trait. cargo test -p okena-workspace 294/294; clean rebuild of okena-app
and the root binary verified against the new generic API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Add okena_remote_server::local — a client-side helper for attaching to a local
daemon over loopback without the interactive pairing dance:
- discover()/discover_in() parse remote.json into a LocalDaemon { port, pid, tls }
- running_daemon() confirms the pid is alive (guards stale remote.json)
- mint_local_token()/_in() mint a bearer token authorized by read access to the
0600 remote_secret (the local-trust model), appending its HMAC to
remote_tokens.json
- is_process_alive() centralized here (server.rs now calls it)
This is the reusable foundation both the CLI and the future desktop daemon-client
use to connect locally. The CLI's register flow now delegates to mint_local_token
(DRY). Core fns are config-dir-parameterized for unit testing.
Tests: 8 new local:: tests; okena-cli (13) and okena-remote-server (36) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
… 1b) Add the process-lifecycle primitives for the UI-owned local daemon: - spawn_daemon() launches this executable as `--headless --listen 127.0.0.1` (loopback port auto-selected); the caller owns the returned Child and kills it when the last UI window closes (UI-owned lifecycle). - wait_until_ready()/_in() polls remote.json until a live daemon is advertised or the timeout elapses, skipping a stale file left by a dead pid. These complete the local-connect toolkit (discover + mint + spawn + wait). The discover-or-spawn orchestration and desktop auto-attach wiring (which changes the desktop startup path) come next as Fáze 1c / Fáze 3. Tests: 3 new wait_* tests; 11 local:: tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Team-facing phased execution plan for collapsing Okena's single-process "local + mirrored-remote" architecture into a two-process daemon/UI split over a local socket, ending with a standalone GPUI-free daemon binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Discover-or-spawn + mint + best-effort reload, returning EnsuredDaemon
{ daemon, token, spawned } where `spawned` is Some only when this call
launched the daemon (UI-owned lifecycle: only the spawner kills it).
Mint-before-spawn so a fresh daemon loads the token at startup; attach
path mints + notifies /v1/auth/reload. Adds running_daemon_in and a
reqwest::blocking reload notifier (enables reqwest "blocking" feature).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…work) Gate the GlobalRemoteInfo wrapper (the crate's only gpui use) behind a `gpui` feature, on by default so consumers are unchanged. The crate now builds gpui-free with --no-default-features. Toward the standalone GPUI-free daemon binary; the dependency-graph gate stays red until okena-workspace/okena-hooks also make gpui optional (bottom-up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Gate the only gpui glue — `impl Global` for HookMonitor/HookRunner, the `&App` accessors (try_monitor/try_runner) and the six `fire_*(&App)` helpers — behind a `gpui` feature, on by default so consumers are unchanged. HookRunner/HookMonitor core logic (PTY-backed execution, status/history) is gpui-free; with the feature off, gpui is entirely absent from okena-hooks' dependency graph. Bottom-up step toward the standalone GPUI-free daemon binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…ccessors The last action-layer methods coupled to &App — add_project, delete_project and the worktree registration chain — only needed &App to read the HookRunner/HookMonitor globals when firing hooks. Extend WorkspaceCx with hook_runner()/hook_monitor() accessors (GPUI impl reads the globals; daemon returns its own), refactor the okena-hooks fire_on_* functions to take the services explicitly instead of &App, and convert the methods to &mut impl WorkspaceCx. A gpui-gated fire_on_worktree_close(&App) wrapper is kept for okena-app's pending-close caller. No behavior change; 294 workspace + 33 hooks tests pass through a real Context. The okena-workspace action layer is now fully GPUI-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
okena-workspace now compiles with zero gpui in the compiled dependency graph (cargo build --no-default-features pulls no gpui), while gpui stays on by default so consumers are unchanged. Gated behind the `gpui` feature: the WorkspaceCx-for-Context impl, GlobalWorkspace, the requests/ request_broker/worktree_sync modules, the ToastManager glue, and the gpui-based test modules (now #[cfg(all(test, feature = "gpui"))]). The WorkspaceCx trait and the whole action/state/data layer stay gpui-free. okena-hooks pulled with default-features = false; feature forwards via gpui = ["dep:gpui", "okena-hooks/gpui"]. A gpui dev-dependency is retained so `cargo test -p okena-workspace` keeps all 294 tests (routing test-support through the feature would alter the shipped binary's gpui feature-set); dev edges never compile into a consumer, so the daemon artifact stays gpui-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…eManager methods ServiceManager's struct was already gpui-free; its methods coupled to GPUI via cx.notify / cx.spawn(|WeakEntity, cx|) / background_executor().spawn / .timer / WeakEntity::update. Abstract exactly those behind three traits (ServiceCx + ServiceHandle + ServiceAsyncCx, the last with a GAT ReentryCx so reentry callbacks get a real ServiceCx again), mirroring the validated WorkspaceCx pattern. All four manager submodules now take `cx: &mut impl ServiceCx`. GPUI impls for Context/WeakEntity/AsyncApp keep existing GUI callers non-breaking (Context<ServiceManager>: ServiceCx). A spike found the per-method boundary doesn't hold — the submodules are mutually recursive through &mut self, so the conversion is all-or-nothing within manager/. Behavior preserved (smol::unblock stays a direct await; update's Result→Option matches all `let _ =` call sites). 33 tests pass, whole workspace builds. gpui stays a hard dep here; making it optional + the tokio reactor impl are the next steps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
With ServiceManager's methods now generic over ServiceCx, gate the GPUI trait implementers (Context/WeakEntity/AsyncApp) and the gpui import behind the `gpui` feature, on by default. No dep forwarding needed (okena-core / okena-terminal are gpui-free). okena-services now builds with zero gpui in its compiled graph; all 33 tests pass with the feature on AND off. This completes the daemon-logic tree: remote-server, hooks, workspace, services are all gpui-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
execute_action only passed its gpui Context through to Workspace action methods (already &mut impl WorkspaceCx) and read GlobalSettings via settings(cx). Make it generic — cx: &mut impl WorkspaceCx — and thread &AppSettings in explicitly (the trait-accessor alternative is blocked by crate layering: okena-workspace can't see app-core's GlobalSettings). Swap the hooks call to the gpui-free resolve_terminal_on_create_simple, drop the gpui imports, update all GUI callers to pass &settings. No behavior change; whole workspace builds, 294 workspace tests pass. gpui stays a hard dep of okena-app-core here; making it optional (+ test gating) is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Gate app-core's own gpui surface behind a `gpui` feature (default on): settings.rs (GlobalSettings/SettingsState/settings(&App)), the gpui test module in execute/project.rs (now #[cfg(all(test, feature = "gpui"))]), and the gpui-only workspace re-exports. Forward the feature to okena-workspace (default-features = false) and add a gpui test-support dev-dependency mirroring okena-workspace — which also fixes the pre-existing isolated `cargo test -p okena-app-core` compile failure (now 10 tests pass). app-core's own code is now gpui-free and the okena-workspace->gpui chain is severed. The compiled-graph gate is not yet green: execute/files.rs uses okena-files (hard gpui + gpui-component) and okena-theme has a hard gpui dep. Making okena-files/theme/ui/markdown gpui-optional is the next (larger) sub-phase toward the standalone gpui-free binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Gate the gpui glue — the whole gpui_helpers module (Hsla/Rgba conversions, GlobalThemeProvider, theme(&App)), GlobalTheme + theme_entity in app_theme — behind a `gpui` feature, on by default. The theme DATA (AppTheme, ThemeMode, ThemeColors/ThemeInfo, custom themes, serde) stays gpui-free, so gpui-free consumers (app-core's ThemeMode, okena-files' file ops) get it with the feature off. Builds with zero gpui in the compiled graph under --no-default-features. Bottom-up step toward the standalone gpui-free binary (okena-files split is next). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Make okena-files gpui-optional: extract the gpui-free scan logic into a new file_scan module (FileEntry + scan_files), keep the ops modules ungated (content_search, list_directory, blame, project_fs, file_scan), and gate the viewer (file_viewer, file_search dialog, content_search_dialog, code_view, syntax, etc.) plus all viewer-only deps (gpui, gpui-component, okena-ui, okena-markdown, syntect, tree-sitter, usvg, image, ...) behind the `gpui` feature. theme.rs re-exports okena-ui gpui helpers so it's gated too. execute/files.rs now calls the gpui-free file_scan::scan_files. Forward the feature from okena-app-core (okena-files/okena-theme with default-features = false). PAYOFF: okena-app-core's compiled production graph is now fully gpui-free — completing the gpui-free daemon dependency tree (remote-server, hooks, workspace, services, app-core). 48 files + 10 app-core tests pass; whole workspace builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
New okena-daemon-core crate (depends on the daemon-tree with default-features = false; zero gpui in its compiled graph). Implements the reactor traits with plain tokio/Arc<Mutex> types: - DaemonReactor: shared state (Arc<Mutex<Workspace>>, Arc<Mutex<ServiceManager>>, state_version + workspace/service dirty-tick watches, held hook services). - DaemonWorkspaceCx: WorkspaceCx (notify bumps the workspace tick, refresh_views no-op, hook_runner/monitor clone the held Arc-backed services). - DaemonServiceHandle/Cx/AsyncCx: the ServiceCx family over Arc<Mutex<ServiceManager>>; update re-locks the mutex, spawn_blocking via tokio, timer via tokio sleep. Validated end-to-end (no hacks). One structural constraint for the wiring step: spawn_main has no Send bound (faithful to GPUI's single-threaded foreground executor), so it uses tokio::task::spawn_local — the daemon's reactor loop must run inside a tokio LocalSet (driveable on a multi-thread runtime). Wiring (observer tasks, pty loop, git poll, command loop) + the okena-daemon binary are the next steps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…emotes Clarify the boundary: daemon owns DATA (projects, layout-as-data, PTYs, git, services, persisted config incl. theme preference); clients own PRESENTATION (rendering, theme application, focus, geometry, CLI formatting). Theme preference is daemon data, never daemon-rendered. Adopt Model A for remotes: the UI is the aggregation hub — it connects directly to its local daemon (loopback) and to each remote daemon, all over the same protocol. The local daemon handles only its own machine (no remote proxying → remote PTY stays one hop). Not a one-way door: remote-connection management can later move into the daemon (Model B, gateway) behind the same protocol. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…ce sync) Replace the GUI's cx.observe wiring with two coalescing-tick tokio tasks: - workspace-tick: bump state_version; debounced autosave (data_version skip, 500ms, spawn_blocking save_workspace); project->services load/unload diff. - service-tick: bump state_version; services->workspace terminal-id write-back via sync_service_terminals. Re-entrancy guards (the known workspace<->services storm): coalescing watch ticks; idempotent diffs (verified sync_service_terminals only notifies when the map changes, so the loop terminates); strictly separate lock scopes (never both mutexes at once). Tasks spawn_local (they drive ServiceManager methods that spawn_local), so the caller runs them inside a LocalSet; ticks are subscribed before spawn to avoid a lost-wakeup race. Autosave errors log instead of toasting (UI-only) and leave last_saved unadvanced to retry. 5 tests pass (sync_services diff + state_version advance on a LocalSet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
run_pty_loop: batch-drains PtyEvents (256 KiB budget), process_output with the registry lock dropped, handles exits via service_manager.handle_service_exit (DaemonServiceCx; restart goes through spawn_local), kills lingering dtach PTYs, bumps state_version. run_git_poll: 5s cadence over all visible non-remote projects, git refresh on spawn_blocking, publishes to the git_status watch + bumps state_version on change. Both gpui-free (added async-channel dep), spawn_local → run inside the LocalSet. Dropped GUI/ server-only bits (window notify, gh PR/CI fan-out, remote-subscribed poll augmentation) — documented; the server can extend the poll set later. 7 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Port okena-app's remote_config.rs (the app-scoped GetSettings/SetSettings/ GetThemes/GetTheme/SetTheme/SaveCustomTheme/GetSettingsSchema handlers) to a gpui-free DaemonConfig backed by a shared Arc<Mutex<AppSettings>>. Following the data-vs-presentation split: the daemon owns the theme *preference* (theme_mode + custom_theme_id, persisted to settings.json) and the custom-theme files; it drops every GlobalTheme/AppTheme entity interaction because applying colors is the client's job. get_theme(None) derives the editable blob's colors from theme_mode directly (Auto -> dark, no windowing system to detect appearance). Adds okena-theme (default-features = false) + serde_json deps; crate stays gpui-free (cargo tree -i gpui still empty). 16 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Pull okena-workspace with default-features = false and forward gpui via the crate's own gpui feature. All okena-workspace uses in remote-server are gpui-free (persistence::config_dir, state::LayoutNode), so with gpui off the crate compiles gpui-free — letting the headless daemon-core link it. The GUI consumer keeps gpui on (default), so GlobalRemoteInfo and the gpui-backed items are unchanged. cargo tree -i gpui -p okena-remote-server --no-default-features is now empty; whole-workspace build (incl. the GUI) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Port okena-app's remote_command_loop to a gpui-free daemon_command_loop that drives the same okena-app-core/okena-services code paths against Arc<Mutex<…>> state and the daemon reactor cx types instead of GPUI entities. Each arm reproduces the GUI behavior arm-for-arm: - service actions: lock ServiceManager, mint a DaemonServiceCx from the shared ServiceReactorRef, same project-path lookup + "project not found" errors; - app-scoped settings/theme: delegate to DaemonConfig / get_settings_schema; - command palette: unavailable (no daemon-side GUI action registry) — ListActions returns [], InvokeAction errors (parity with current headless mode); - workspace actions: execute_action against the synthetic WindowId::Main; - GetState: the full StateResponse builder with a single synthetic "main" window (ported from headless.rs's windows_resolver); - GetTerminalSizes / RenderSnapshot / PasteImage via ensure_terminal. The loop owns one dormant FocusManager (it is single-threaded). Every arm is synchronous (no .await while a state guard is held), so guards drop before the next recv() — the same lock discipline as pty_loop. Adds okena-app-core, okena-remote-server (both default-features = false) and uuid deps; crate stays gpui-free. 24 tests pass (8 new). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Wire the whole gpui-free daemon together — the headless analogue of okena-app's HeadlessApp. DaemonCore::new builds the PTY manager + broadcaster, terminal registry + backend, workspace + DaemonReactor, the shared settings cell + DaemonConfig, and the server-wiring channels, then starts the RemoteServer and prints its pairing info. DaemonCore::run drives the reactor on a LocalSet: spawn_observers + run_pty_loop + run_git_poll, with daemon_command_loop as the main task raced against ctrl-c. Key wiring details: - state_version is shared via a cloned watch::Sender (clones share one channel), so the server/command-loop reads observe the reactor's observer/pty/git bumps; - the reactor tasks use spawn_local, so they must run inside LocalSet::block_on on the multi-thread runtime (the runtime Handle still serves spawn_blocking); - the RemoteServer owns its own internal runtime and talks to the daemon only through the channels; - hooks are None for now (parity with current headless mode) — a follow-up. anyhow moves dev->regular (the new::/run:: anyhow::Result API needs it at runtime); tokio gains the "signal" feature. Crate stays gpui-free; 24 tests pass; whole workspace builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
New crate okena-daemon: a shippable, 100% GPUI-free headless server binary that boots okena_daemon_core::DaemonCore. It mirrors the GUI's run_headless bootstrap without GPUI — loads settings via okena_workspace::settings::load_settings(), loads (or defaults) the workspace, takes session backend / listen addr / TLS from settings (with a --listen override), then DaemonCore::new(params)?.run(). This is the payoff of the headless-migration groundwork: the daemon dependency tree is fully gpui-free. Final gate cargo tree -i gpui -p okena-daemon is empty. Smoke-verified end-to-end (isolated XDG_CONFIG_HOME): the binary boots, loads settings/workspace, auto-detects dtach, starts the dual-stack TLS remote server, prints a pairing code, writes remote.json, runs the observer reactor on the LocalSet (load_project_services fires), serves /health, and — after pairing — returns a full StateResponse from /v1/state, exercising the whole RemoteServer -> bridge -> daemon_command_loop -> GetState path. Wiring the desktop's spawn_daemon to launch this binary (instead of `okena --headless`) is a follow-up (the desktop-as-client phase). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Two changes wiring the standalone binary into the UI-owned lifecycle: 1. okena-daemon binary now resolves the active profile at startup (resolve_active_profile(None) -> init_profile -> migrate), exactly like the GUI binary. The spawning desktop propagates OKENA_PROFILE to the child, so the daemon lands in the SAME profile config dir (verified: with OKENA_PROFILE=default it loads settings + writes remote.json under okena/profiles/default/). A standalone daemon reads the default/last-used profile. Adds the gpui-free okena-core dep; daemon stays gpui-free. 2. spawn_daemon() now prefers the dedicated okena-daemon binary (resolved as a sibling of current_exe, honoring the platform exe suffix) and falls back to `current_exe --headless --listen` when it isn't present (transition safety). The child inherits OKENA_PROFILE, so daemon + UI share config. Only reached via ensure_local_daemon (not yet wired into desktop startup), so no live behavior change yet. okena-remote-server tests: 41 pass (new daemon_binary_path test). Whole workspace (incl. GUI) builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…seams From a full map of the desktop's local-vs-remote rendering path: the one hard ordering constraint for desktop-as-client is moving the instance lock + workspace.json writes to the daemon BEFORE the desktop attaches (else both clobber the profile). Records the concrete step (DaemonCore acquires the lock, client Workspace becomes mirror-only) plus the exact file:line seams the reused remote path touches (ActionDispatcher, create_local_column, apply_remote_snapshot, the is_remote provider branches, the in-process pieces that become daemon-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Phase A of the daemon/client migration. Adds a `--daemon-client` flag to the desktop, OFF by default (classic behavior byte-for-byte unchanged). When ON, the desktop runs as a thin client of a local headless daemon: - main.rs skips `acquire_instance_lock()` (the daemon is the single writer, §5), starts with an empty `WorkspaceData::empty()` (the daemon owns the real workspace; projects arrive via the mirror snapshot), and calls `ensure_local_daemon()` (discover-or-spawn + mint a loopback token). - `Okena::new` takes `local_daemon: Option<EnsuredDaemon>`; when set it registers an implicit, trusted loopback `RemoteConnectionConfig` (id "local-daemon", tls off) via `RemoteConnectionManager::add_connection`, holds the spawned `Child`, and kills it on `on_app_quit` (UI-owned lifecycle — only the spawner kills; attached daemons are left running). - The workspace autosave observer is made inert in client mode (§5 single-writer: the GUI's Workspace is a pure mirror, never writes workspace.json). The loopback connection never reaches persisted settings: `add_connection` does not persist, and the only insertion site (RemoteConnected overlay event) is never fired for it. No `as`/`unsafe`/type-hacks. Build + 405 tests green (okena-app/workspace/remote-client/remote-server). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
Phase B prerequisite (§5 single-writer). The dedicated `okena-daemon` binary never acquired the instance lock, yet `spawn_daemon` *prefers* it over the `current_exe --headless` fallback (which does lock via main.rs). Combined with the new `--daemon-client` GUI deliberately skipping the lock, that left a window where NO process owned the profile lock — a second classic GUI could then clobber `workspace.json`. `DaemonCore::new` now acquires `acquire_instance_lock()` as step 0 (before binding a port or writing `remote.json`, so a collision fails fast with no side effects) and holds the `LockGuard` for the daemon's lifetime, released on drop at the end of `run`. `acquire_instance_lock`/`LockGuard` are gpui-free (std::fs + libc), so the daemon stays gpui-free. No double-acquire: the binary's main does not lock; only DaemonCore does. Build green (daemon-core + daemon); 24/24 daemon-core tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QLQ4mQS87SLSmbTihiAqX
…ient Closing a full-screen program (e.g. nvim) left a stray `6c` at the shell prompt on macOS: the render-only remote client was *also* answering terminal queries (Device Attributes / DSR / DECRQM). Its `TerminalTransport::send_response` defaulted to `send_input`, so the client emitted a duplicate `ESC[?6c` reply and round-tripped it over the WebSocket back to the daemon's PTY — arriving long after the program exited, so the shell received it. Add a dedicated `send_response` transport hook for terminal→program replies: - the local PTY (`PtyManager`) writes them synchronously via a shared-writer fast lane (`write_response`), ahead of the batched input queue; - the remote client makes it a no-op, so the daemon that owns the PTY is the sole responder (correct, and no WS round-trip that lands late at the shell). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
Resolve the `clippy --workspace --all-targets -D warnings` failures on #157: - auth.rs: drop needless borrow in `std::fs::read(&path)` - persistence.rs: build `WindowState` via struct-init instead of field-reassign-after-`default()` (×2, test) - stream.rs: move `mod tests` below the helper fns (items-after-test-module) - port_detect.rs: gate `parse_lsof_output` on `cfg(test)` — macOS scans sockets via libproc, so it was dead outside tests - command_loop.rs / status_bar.rs: collapse nested `if` into the outer condition/match Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The local and remote okena kept stealing terminal sizes from each other
(~2Hz grid flapping). Three protocol defects let resize authority drift
away from the side the user actually typed on:
1. Mirror terminals answered emulator queries (DSR cursor reports, DA,
OSC 10/11/4 colors, CSI 14/18 t) by sending the reply as SendText.
The daemon counts SendText from a connection as user input and
transfers resize ownership to it — so every Claude-Code-style query
flipped ownership to whichever mirror answered last, and the PTY got
duplicate replies (one per connected client on top of the daemon's).
Mirrors now stay silent (answers_terminal_queries=false, like the
mobile client always did); the daemon's own Term is the single
responder. Since no view pushes per-terminal palettes in the daemon,
OSC color queries are answered from a process-wide palette seeded at
boot and on theme changes.
2. Identityless HTTP actions (/v1/actions: CLI, agents) nulled the
resize owner on any SendText/RunCommand/SendSpecialKey, handing the
next arriving resize to a random client. They no longer touch resize
authority; only input from an identified WS connection transfers it.
3. A denied resize was dropped silently, leaving the denied client's
optimistically-resized grid diverged from the PTY with no correction.
The daemon now replies with the authoritative size and the stream
handler bounces it back as TerminalResized{server_owns:true}, so the
client reverts its grid and cedes instead of re-asserting.
Also scope the client-side resize authority per connection (derived from
the `remote:<connection>:` id prefix): another server's owner reclaiming
no longer stops a client from resizing an unrelated server's terminals
(daemon-side plain ids keep sharing one scope, preserving the
process-global "last to interact wins" semantics per server).
Verified against a live daemon with two clients: typing transfers
ownership, non-owner resizes are denied + corrected, HTTP input no
longer releases the owner, and a WINCH-query TUI no longer causes any
ownership churn while a GUI client is attached.
Note: the web client (xterm.js) still auto-answers queries and can steal
ownership the same way — follow-up needed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhZ6UQDgoBvznZgo3BQvQq
POST /v1/shutdown (local-only, gated like /v1/restart) refuses while any live WS client is connected, else tears the daemon down cleanly: unlink the local socket, remove remote.json (only when it still advertises our own pid), release the instance lock. Live clients are counted via a WS connection counter registered after auth in the stream route. Groundwork for replacing the GUI's SIGKILL-at-quit, which could kill the daemon out from under a freshly attached client on quick GUI restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B99aR6jZR2wHEtgLJVy36
Quit no longer SIGKILLs the owned daemon: disconnect our loopback connection, request /v1/shutdown, and kill only when the daemon accepted but failed to exit within 3s. A refusal (another client attached, e.g. a quickly restarted GUI) leaves the daemon running — previously that race left the new GUI dialing a dead unix socket forever. As a safety net the local connection now self-heals: on a terminal Error the app re-runs ensure_local_daemon (attach or respawn) with backoff and re-points the connection. Local retry budgets are cut (5 initial attempts, 3x1s WS reconnect) so recovery kicks in within a few seconds instead of ~30-180s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B99aR6jZR2wHEtgLJVy36
The stale-dtach-socket GC matched every *.sock in the runtime dir — including the daemon's control socket, which shares the directory. The GC thread races the daemon's own boot: it snapshots /proc before the daemon re-binds a stale socket path and unlinks the freshly bound socket after, leaving the daemon listening on an anonymous inode while every client connect gets ENOENT. Since the failed run consumed the stale file, the next start had no GC candidate and worked — producing a reproducible fail/ok alternation on restart. Restrict candidates to the dtach naming scheme (shared "tm-" prefix) and skip files younger than 60s as a TOCTOU guard for the remaining candidates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B99aR6jZR2wHEtgLJVy36
The okena --headless fallback's /v1/shutdown path exits via process::exit, so Drop-based cleanup never ran and every graceful quit left a stale unix socket file and okena.lock behind — the stale socket being the trigger for the dtach GC race fixed in the previous commit. Before exiting, unlink the advertised local socket and remove the instance lock, both pid-guarded so a late execution can never touch a successor's files. Extract the shared read+pid-guard for remote.json so both removal helpers stay in sync, and expose instance_lock_path() mirroring acquire_instance_lock's resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B99aR6jZR2wHEtgLJVy36
Self-heal previously looped forever when remote.json advertised a live pid whose endpoint never answers (e.g. a daemon whose socket file was unlinked out from under it): the ensure attach path waited 30s, erred, and retried into the same wall. Split ensure_local_daemon patience into attach vs spawn timeouts so recovery can give a wedged existing daemon only 8s while a freshly spawned child keeps the full 30s boot budget (a short deadline would SIGKILL every mid-boot respawn on a loaded machine). After two failed attempts, recovery re-probes the advertised endpoint with a 3s timeout and, if the daemon is alive but unresponsive, kills it so the next attempt takes the spawn path. kill_process_by_pid now verifies the target is an okena process (guards against pid recycling), and the recovery/attach paths reap the owned child handle to avoid zombies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B99aR6jZR2wHEtgLJVy36
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FH3tw4uhVnjXJjNApumNLk
The immediate-refresh trigger from ee49c8f only reached the dedicated okena-daemon (DaemonCore). On the single-binary `okena --headless` daemon the trigger channel was None and the GitStatusWatcher had no external wake-up, and the desktop's ProjectColumn refresh was dead code (git_watcher is always None in daemon-client mode). Per-window visibility is client-owned, so the daemon only learned a project was on screen once a terminal subscribed — hence git/PR/CI status appeared only after opening a terminal. - Desktop now sends a GitStatus action when a ProjectColumn becomes visible (mirrors the web client's request-on-visible). - HeadlessApp wires the same git-poll trigger channel as DaemonCore and a consumer task drives the GitStatusWatcher via apply_poll_trigger. - Share git_poll_trigger_for_action in okena-core (dedup both daemons). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FH3tw4uhVnjXJjNApumNLk
It is the standard single-binary way to run the UI's daemon, not a transitional/fallback path; okena-daemon is the optional lighter GPUI-free variant used when present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FH3tw4uhVnjXJjNApumNLk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full Headless Mode — daemon + thin clients
Migrates Okena from the single-process "local + mirrored-remote" architecture to a
two-process model: a headless daemon that owns all state, PTYs, logic and
persistence, and thin UI clients (desktop, web, mobile, remote) that all speak one
protocol over a socket. The in-process "local" code path is deleted; local and remote
projects now render through the same machinery.
Why
daemon (authoritative) and clients (views).
okena-daemonbuilds and runs with zero gpui linked(gate:
cargo tree -i gpui -p okena-daemonis empty), so it runs on a headlessserver / CI box / container with no windowing stack.
the daemon dies with the last UI (UI-owned lifecycle).
The boundary: DATA vs PRESENTATION
services, hooks, persisted config (incl. the theme preference), instance lock,
HTTP/WS server.
per-window visibility — and, for the CLI, output formatting.
What's in here
okena-daemon-corecrate (gpui-free): tokio reactor, observer reactor(autosave /
state_version/ service-sync), the PTY event loop, git-status poller,gpui-free settings/theme handlers, and the gpui-free command loop — assembled by
DaemonCore::{new,run}.okena-daemonbinary — first-class shippable artifact, now packagedby the installers.
okena-workspace,okena-theme,okena-services,okena-hooks,okena-files,okena-app-core,okena-remote-server(reactor-traitabstractions sever the gpui chain).
--daemon-client→ default): deleted theGUI's in-process
RemoteServer, PTY manager, git/worktree watchers, in-processServiceManagerand autosave observer.hooks, worktree create/close, branch switch/create, file viewer rename/delete,
project rename, shell switch, soft-close (Undo / Close-now), toasts + toast actions,
OS notifications, CI/PR enrichment, terminal buffer export, client-owned multi-window
layout persistence.
Scope
100 commits · 154 files · +12.4k / −4.4k. The daemon-core is complete with tests; the
gpui gate is empty; the standalone binary ships. Architectural goal met per the
roadmap doc; remaining items there are last-mile parity, not blockers.
Testing
Every commit is
cargo build/cargo testgreen; the gpui-free gate(
cargo tree -i gpui -p okena-daemon) is verified empty.