Skip to content

Full Headless Mode: daemon + thin clients - #157

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

Full Headless Mode: daemon + thin clients#157
matej21 wants to merge 365 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
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>
matej21 and others added 16 commits July 22, 2026 21:14
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 13 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
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.
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