Skip to content

Full Headless Mode: daemon + thin clients#157

Open
matej21 wants to merge 357 commits into
mainfrom
refactorx/full-headless
Open

Full Headless Mode: daemon + thin clients#157
matej21 wants to merge 357 commits into
mainfrom
refactorx/full-headless

Conversation

@matej21

@matej21 matej21 commented Jun 28, 2026

Copy link
Copy Markdown
Member

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.

Full design + execution log: docs/headless-migration.md.

Why

  • One architecture — no more parallel "local" vs "remote" code paths. There's the
    daemon (authoritative) and clients (views).
  • The daemon is GPUI-freeokena-daemon builds and runs with zero gpui linked
    (gate: cargo tree -i gpui -p okena-daemon is empty), so it runs on a headless
    server / CI box / container with no windowing stack.
  • Desktop runs as two processes — the first UI spawns a local daemon over loopback;
    the daemon dies with the last UI (UI-owned lifecycle).

The boundary: DATA vs PRESENTATION

  • Daemon owns data: projects, layout-as-data, terminals + PTYs, git status,
    services, hooks, persisted config (incl. the theme preference), instance lock,
    HTTP/WS server.
  • Client owns presentation: rendering, applying the theme, focus, window geometry,
    per-window visibility — and, for the CLI, output formatting.

What's in here

  • New okena-daemon-core crate (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}.
  • Standalone okena-daemon binary — first-class shippable artifact, now packaged
    by the installers.
  • GPUI made optional across okena-workspace, okena-theme, okena-services,
    okena-hooks, okena-files, okena-app-core, okena-remote-server (reactor-trait
    abstractions sever the gpui chain).
  • Desktop flipped to always-a-client (--daemon-client → default): deleted the
    GUI's in-process RemoteServer, PTY manager, git/worktree watchers, in-process
    ServiceManager and autosave observer.
  • Protocol parity — routed through the daemon end to end: sessions, lifecycle
    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 test green; the gpui-free gate
(cargo tree -i gpui -p okena-daemon) is verified empty.

@matej21
matej21 force-pushed the refactorx/full-headless branch from 89b6931 to dcf0ae5 Compare June 30, 2026 15:40
@jonasnobile
jonasnobile force-pushed the refactorx/full-headless branch from d2012b2 to 95cbe89 Compare July 2, 2026 09:51
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>
matej21 and others added 27 commits July 14, 2026 13:38
RemoteServer::start runs on a plain caller thread (daemon main / GPUI
thread). TCP listeners bind via runtime.block_on, but the local unix
socket bound via bind_unix_socket directly, with no ambient runtime, so
UnixListener::bind panicked with "there is no reactor running". Enter
the runtime explicitly around the unix socket bind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWzd61qT2vWjhDnE7vTjVQ
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
matej21 and others added 24 commits July 22, 2026 20:16
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.
neumie and others added 5 commits July 24, 2026 13:16
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants