Full Headless Mode: daemon + thin clients - #157
Open
matej21 wants to merge 365 commits into
Open
Conversation
matej21
force-pushed
the
refactorx/full-headless
branch
from
June 30, 2026 15:40
89b6931 to
dcf0ae5
Compare
jonasnobile
force-pushed
the
refactorx/full-headless
branch
from
July 2, 2026 09:51
d2012b2 to
95cbe89
Compare
jonasnobile
added a commit
that referenced
this pull request
Jul 2, 2026
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>
PR #152 added `default_branch` to GitStatus so the ahead/behind chip hides the redundant base label when the review base IS the default branch (the common case: `⎇ +2` instead of `⎇ main +2`). But the desktop is now a pure daemon-client — `git_watcher` is always `None`, so `resolve_git_status` always reads the remote snapshot, where `default_branch` was hard-coded `None`. With it always `None`, `show_label` was always true and the label never hid: #152's feature was dead over the daemon connection. Carry it over the wire exactly like `review_base` (08a7769): - Add `default_branch: Option<String>` to `ApiGitStatus` (serde default). - Populate it in both GitStatus→ApiGitStatus converters (daemon git_poll, GUI watcher). - Map it through in project_column's remote-snapshot fallback so the base label hides on the default branch for daemon-backed projects too. okena-core api round-trip covers default_branch; workspace builds clean, daemon-core 30 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJrTmHn7J5eX8okTcggSwV
…oops Soft-close (busy-terminal grace + Undo/Close-now toast) lived only in okena-daemon-core; the `okena --headless` fallback (used when the okena-daemon binary isn't built) closed immediately with no toast. Extract the orchestration into a shared, runtime-agnostic engine in okena-workspace (actions::soft_close: probe_busy, build_soft_close_toast, begin_soft_close_flow, undo_soft_close_flow, close_now_flow, finalize_expired, SoftCloseDeadlines) and have both command loops call it. Headless gains a HookMonitor + deadline map + finalizer-tick and toast-drain spawn loops. Also log when spawn_daemon falls back to --headless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
The daemon-core and headless command loops carried byte-parallel copies of the GetState snapshot builder (build_api_project + folder/orphan assembly) and the six service-action arms — every wire/loop feature had to be added twice, which is how soft-close and other features silently diverged. Extract the pure snapshot construction into okena-app-core::remote_snapshot (build_api_project, build_api_projects, build_folders, build_state_response, api_project_visibility) and the service-action logic into ServiceManager methods generic over ServiceCx (start/stop/restart/start_all/stop_all/reload) plus ServiceInstance::to_api. Both loops now gather their own runtime state (Mutex vs gpui Entity) and call the shared code. Wire shape is unchanged. Each loop sheds ~190 lines. Config modules (daemon_config vs remote_config) remain parallel — deferred as a higher-risk follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
…trait daemon_config (Arc<Mutex<AppSettings>> + disk) and remote_config (gpui GlobalSettings/GlobalTheme entities) were parallel copies of the same settings/theme logic. Extract the shared logic into okena-app-core::remote_config over a ConfigBackend trait that abstracts the three divergent pieces: settings load/store, live-theme apply (no-op on the headless daemon), and the active-theme color source. Daemon and GUI each impl the trait; the get/set settings, theme list/get/set, save-custom-theme, schema, JSON-merge and theme-resolution helpers now live once. Wire shape, error strings, GUI save_and_notify + live AppTheme update, and daemon disk persistence are all preserved. list_actions stays GUI-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
get_settings/get_themes now take &mut App, matching cx.update's closure signature, so `cx.update(|cx| f(cx))` is a redundant_closure under `clippy -D warnings`. Pass the fn directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
The footer's REMOTE endpoint and Pair button used terminal ANSI accents (term_cyan / term_yellow), which read inconsistently against the rest of the footer chrome (version/time/Focused use the neutral UI palette) — most visibly in the Pastel theme. Switch REMOTE to text_secondary (gray) and Pair to text_primary (white, keeping the hover background), so the footer is consistent across all themes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
Rework the shared toast overlay (used by all levels + the soft-close alert) to match okena's notification style: drop the left accent stripe, put a level-colored badge on the left (circle for success/info/error, triangle glyph for warning), a bold title over a muted subtitle, close in the title row, and plain left-aligned text-link actions. The grace countdown is now a subtle accent wash that depletes across the whole card instead of a thin bottom line. Align the first action's text flush with the title (offset its hover-pill padding), bump the countdown-wash contrast and subtitle legibility, and even out the padding/margins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
Connection ids are process-unique and ownership was never released when the owning WS connection closed, so a reconnecting client (new id) had its resizes silently denied by the dead owner until it sent input. Release the owner in the handle_ws cleanup: keep remote authority but drop the owner id, so the next resize from any connection adopts it. A full reset to unclaimed would instead report local authority and make the daemon broadcast server_owns=true with no local user present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016d7Y4vNyU3kFAY85e3YaTF
Splitting, adding a tab, or creating a terminal next to a focused one now opens it in that terminal's live shell cwd (OSC 7, so it follows `cd`), falling back to the project path. A shared inherited_cwd() resolves the source terminal from the action's layout path (client-independent — correct in the daemon model) and threads it into spawn_uninitialized_terminals. Fresh projects, worktrees, sessions, startup materialization and shell-switch keep the project path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qxP1RVoywgidDU68WDpr3
…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>
Release synchronous workspace locks before async cleanup, bundle oversized handler arguments, and apply the remaining behavior-neutral lint fixes exposed after the services errors were resolved.
The single-binary daemon (`okena --headless [--ui-owned]`) reuses the same src/main.rs logging init as the GUI, so both processes rotated and wrote the same okena.log, clobbering each other's history. The standalone okena-daemon.log tee only exists in the separate okena-daemon binary, so ui-owned mode produced no daemon log at all. Pick the log filename before the rotate/create block: when headless (or Linux --listen/--remote with no display), write okena-headless.log with its own .1 rotation, leaving the GUI's okena.log legible.
Reconcile dtach sessions against authoritative post-lock workspace state, isolate named-profile sockets, and tear down verified descendant trees before removing their masters.
Route remote terminal activity through the existing doorbell and app-wide pane registry instead of per-pane 8 ms timers.
Preserve birth-marker validation while allowing a stopped dtach master to reap killed children after teardown verification.
8 tasks
fix: recover failed worktree close teardown
`tmux kill-session` and `screen -X quit` exit non-zero when the session is already gone, which is the normal outcome after a shell exits on its own — the PTY EOF that reaches `kill_exited` fires precisely because the session terminated. Treating that exit status as a teardown failure made `ResolvedBackend::kill_session` report false, mark the teardown tracker failed, and abort the next worktree/project close with "checkout preserved". The liveness probe already answers the question teardown actually asks, and `has-session` / `-Q select .` correctly report a missing session as not live. Fall through to it on a non-zero kill status; keep failing closed only when the kill command could not be run at all. Affects tmux, screen and psmux; dtach has its own socket-based path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HqeVbA9vP6t9EL7rFiNznF
The reaper worker decrements the teardown tracker for every job it drains, but `shutdown_handle` took the matching count conditionally. `detach_all` passed no tracker while still passing the reaper sender, so a handle that reached the reaper was decremented without ever having been counted; a closed reaper queue did the reverse and left a count no worker would ever complete. Only `Drop` calls `detach_all` today, so neither could be observed by a flush. Move the handoff into one helper that takes the count exactly while a reaper owns the job, and pass the tracker from `detach_all`, so the invariant holds locally instead of depending on that call graph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HqeVbA9vP6t9EL7rFiNznF
…eted The teardown tracker recorded an unverified session kill as a single manager-wide AtomicBool that any flush consumed. Three consequences: - a failed kill for one terminal aborted the next destructive operation on an unrelated project, with a message naming that project's checkout; - `flush()` cleared the flag, so a plain drain between the failure and the destructive flush erased the signal and the removal proceeded; - `swap` meant only the first of two concurrent flushes saw the failure and the second reported success — both fail-open, defeating the point. Record unverified teardown per terminal instead, keyed off the owning terminal id now carried on `TeardownKind::KillSession`, and have the bounded flush take the terminals the caller is about to delete. Reading is non-destructive so concurrent waiters agree; an entry is cleared only when a later teardown of that same terminal succeeds. Both destructive call sites already know their terminals. The hook paths additionally pass the before-remove hook terminal they killed themselves, which the project row no longer lists — the global flag covered it only by accident. Daemon shutdown asks about the drain alone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HqeVbA9vP6t9EL7rFiNznF
Batch repaint-only terminal activity at an app-wide presentation cadence and defer inactive-window paints while keeping parsing, notifications, focus reporting, and OSC clipboard handling immediate.
Keep visible and remotely subscribed projects responsive while moving hidden repositories to bounded fallback cadences. Preserve cached statuses across targeted polls and anchor periodic work to wall-clock deadlines.
Do not infer visibility from key-window activation, because a visible window on another monitor is normally non-key. Keep every pane and sidebar on the shared capped repaint cadence while focus reporting remains scoped to the actual key window.
Use a leading-edge repaint for the first output after idle, then coalesce sustained activity into 20 ms frames. This keeps typed echo responsive while bounding high-frequency terminal presentation across all windows.
Track a causal output epoch for each input so parsed post-input output bypasses an active 20 ms presentation batch. Keep unrelated terminal activity queued, expire unanswered requests, and cover backlog and enqueue races.
Trace input through the daemon, PTY, repaint throttle, and GPUI frame boundaries. Keep the instrumentation behind a Cargo feature and require an explicit runtime opt-in.
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.