Skip to content

feat(remote-session): Stage 2 client-attach (part 1: client protocol) [draft]#16

Draft
iret77 wants to merge 76 commits into
mainfrom
feat/stage2-client-attach
Draft

feat(remote-session): Stage 2 client-attach (part 1: client protocol) [draft]#16
iret77 wants to merge 76 commits into
mainfrom
feat/stage2-client-attach

Conversation

@iret77

@iret77 iret77 commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Draft — Stage 2, increment 1 (client protocol layer). Builds on Stage 1 (daemon session host, merged).

Enthalten

  • Design-Spec docs/superpowers/specs/2026-06-25-stage2-client-attach-design.md (Seams, Insertion-Point, Increment-Plan).
  • Client-Protokoll-Schicht (nur remote_server-Crate):
    • ClientEvent::SessionOutput / SessionExited + push_message_to_event-Arme.
    • Client-Methoden: open_session, attach_session (Request/Response) und send_session_input / send_resize_session / send_detach_session (Notifications).
    • forward_client_event: Platzhalter-Arm für die neuen Events (noch kein App-Consumer → zur Laufzeit nicht getroffen).

Verifiziert

cargo check -p remote_server (lib + tests) grün. Isoliert auf den Crate — App-Build bleibt unberührt (Gate bestätigt).

Offen (nächste Increments, siehe Spec §3)

  1. Manager→App-Events; 3. Remote-Terminal-Byte-Source (SessionOutputansi::Processor::parse_bytes → Grid/Blocks); 4. Input/Resize/Maus via send_session_input; 5. Session-Lifecycle/-Typ. Das ist die schwere app-seitige Integration — bewusst eigener Increment.

Der Kern-Insertion-Point: SessionOutput.bytesProcessor::parse_bytes(&mut terminal_model, &bytes, &mut io::sink()) (identisch zum lokalen Pfad, nur ohne lokales Echo).

iret77 and others added 30 commits June 25, 2026 16:23
Client side of the native session host, isolated to the remote_server crate:
- ClientEvent::SessionOutput / SessionExited + push_message_to_event arms.
- Client methods: open_session, attach_session (request/response) and
  send_session_input / send_resize_session / send_detach_session (notifications).
- forward_client_event: placeholder arm for the new events (no app consumer
  until the app-side terminal increment; not hit at runtime yet).

cargo check -p remote_server (lib + tests) green. App-side byte-flow / input /
mouse wiring is the next increment (see the Stage 2 design spec).
Add RemoteServerManagerEvent::SessionOutput / SessionExited (carrying the
connection session_id + host_id + daemon pty_session_id + seq/bytes or
exit_code) and emit them from forward_client_event (replacing the increment-1
placeholder). Update session_id() and the three exhaustive consumers
(session.rs, remote_server_controller.rs, view.rs) to handle the new variants
(no-op for now). The attached-remote terminal byte-source that *subscribes* to
these and feeds the ansi processor is increment 3.

cargo check -p remote_server and -p warp both green.
Add `daemon_tty`, a new `TerminalManager` implementation for sessions whose
PTY lives in the remote daemon and survives transport drops. It is a sibling
of `remote_tty`, not a fork: both reuse the shared terminal-manager helpers
(`create_terminal_model`, `init_pty_controller_model`,
`wire_up_pty_controller_with_view`) and differ only in their event loop's
transport.

The daemon event loop is transport-agnostic (the seam for a future native
UDP transport): live PTY output arrives as `RemoteServerManagerEvent::SessionOutput`
pushes filtered by `pty_session_id`, and input/resize/detach are routed back
through the live `RemoteServerClient` (`send_session_input` /
`send_resize_session` / `send_detach_session`). Input that arrives before
`OpenSession` resolves is buffered and flushed in order. Shell bootstrap is
intentionally left to the daemon (server-side, once) so a later reattach stays
clean.

Not yet wired into `create_session` (that is increment 3b); `local_tty`
(localhost + plain-vanilla SSH) remains the untouched default. Verified in
isolation with `cargo check -p warp` (green).
…nce setting

Add the per-host opt-in for the native persistent remote-session layer (the
trigger for Option B: a resilient host opens directly as a daemon-hosted
session). This increment is the data model only — additive, default off, zero
behavior change; the runtime trigger (open_ssh_terminal → daemon_tty, headless
connect) is the next increment.

- DB: additive migration adds `ssh_servers.session_resilience TEXT NOT NULL
  DEFAULT 'off'`. Down uses DROP COLUMN (safe on the bundled SQLite >= 3.35;
  a hand-rebuilt table would be more error-prone now that the schema carries a
  modified auth_type CHECK and a credential_id FK).
- `SessionResilience` enum (Off | PersistOnly | PersistPlusMosh) with
  as_db_str/parse/is_enabled, mirroring AuthType. Added to SshServerInfo, the
  persistence Row/NewRow + schema, and threaded through create/update/read CRUD.
  The read path is lenient: an unknown value degrades to Off rather than making
  a server unloadable.
- Cloud sync: SyncServer carries the field too (serde default "off" for older
  payloads) so sync-down preserves a host's setting instead of resetting it.
- Updated all SshServerInfo constructors; the server form preserves the stored
  value on edit (no UI control until Stage 4).

Verified: cargo check -p persistence -p warp_ssh_manager --tests and
cargo check -p warp both green. App-side test files verified via the xl
test-dispatch job.
…igger (Option B)

Captures the design for wiring a resilient SSH host to open directly as a
daemon-hosted session: headless ControlMaster connect, daemon_tty deferring
OpenSession until SessionConnected, the open_ssh_terminal branch, and the
3c-i/ii/iii increment breakdown. Records open scope decisions (v1 auth support,
tab/connect ordering, ControlMaster lifecycle).
…Session until connected

daemon_tty no longer assumes an already-connected transport (3a's assumption).
The OpenSession request is held in `pending_open` and issued by `try_open` only
once a client exists: at startup if already connected, otherwise driven by a new
`RemoteServerManagerEvent::SessionConnected` arm matching this session's id. A
`SessionConnectionFailed` arm drops the pending open (Stage 3 will surface an
error block). This is the prerequisite for Option B's immediate-tab-then-connect
flow. cargo check -p warp green.
…ns as daemon session

Wire Option B end-to-end: a saved SSH host with session_resilience enabled
opens directly as a daemon-hosted (native persistent) session instead of a
local PTY running `ssh`. local_tty stays the untouched default for every
ordinary terminal.

3c-ii — headless connect (app/src/remote_server/headless_connect.rs, unix):
- alloc_daemon_session_id(): mints SessionIds in the top half of the u64 space
  so they can't collide with shell-bootstrap-minted ids.
- is_headless_capable(): v1 = key auth only (runs under BatchMode=yes); password
  hosts fall back to the normal SSH path.
- control_socket_path(): stable per-host local ControlPath under $HOME/.ssh.
- ensure_control_master(): spawns `ssh -f -N -o ControlMaster=auto
  -o ControlPersist=yes -o ControlPath=… -o BatchMode=yes …`, awaits the socket.

3c-iii — trigger + tab plumbing:
- DaemonSessionRequest (connection_session_id + OpenSessionParams), carried
  additively through NewTerminalOptions into create_session; a new daemon branch
  there routes to daemon_tty::create_model (all other call sites pass None).
- open_ssh_terminal: try_open_daemon_ssh_terminal() takes the daemon path for
  resilient+headless-capable hosts — creates the daemon tab (which subscribes for
  SessionConnected, 3c-i) then spawn_daemon_session_connect() establishes the
  ControlMaster and calls RemoteServerManager::connect_session on the allocated
  id. connect_session emits SessionConnected → daemon_tty issues OpenSession.

Compile-verified (cargo check -p warp green). Runtime behaviour (connect, stream,
mouse, survive drop) needs validation against a real resilient host.
Add a Standard/Persistent toggle to the SSH server detail form so
`session_resilience` is manageable in the UI (no DB editing needed). Styled
like the existing auth toggle. The form loads the stored value, and Save +
Connect submit the live selection, so toggling "Persistent" on a key-auth host
makes "open" take the daemon-hosted session path (3c).

Toggling on selects PersistOnly but preserves a higher tier (PersistPlusMosh)
if already set; the UI only distinguishes off vs. on for now (B3 mosh not built).
cargo check -p warp green.
The SSH/shell-enrichment feature inherited from Warp (two forks upstream) was
still called "Warpify". Rename it end-to-end to "Zaplexify" while there are no
users yet (so no settings migration is needed — now is the only safe time):

- All word-forms: warpify/Warpify/WARPIFY and warpification/Warpified/etc.
  (identifiers, the terminal::warpify module → zaplexify, settings types,
  enum variants like WarpifiedRemote → ZaplexifiedRemote).
- Persisted settings keys: warpify.ssh.* / warpify.subshells.* →
  zaplexify.* (no migration — first version).
- User-facing strings + i18n keys (settings-warpify-* → settings-zaplexify-*;
  validated by the t! macro at compile time).
- Bundled SSH bootstrap scripts renamed (warpify_ssh_session.sh →
  zaplexify_ssh_session.sh, install_tmux_and_warpify_* → *_zaplexify_*) plus
  their bundled_asset! references.

Deliberately NOT touched (separate concerns): the inherited `WARP_*` terminal
protocol/env namespace, the `~/.warp` config dir, TERM_PROGRAM="WarpTerminal",
and the warp_*/zap_* crate prefixes (provenance naming scheme). cargo check
-p warp green.
…RAM to Zaplex

Rebrand the inherited Warp terminal-integration protocol surface (env vars,
markers, consts) and terminal identity:
- All 143 WARP_* names → ZAPLEX_* (e.g. WARP_SESSION_ID, WARP_HONOR_PS1,
  WARP_BOOTSTRAPPED, WARP_COMPLETIONS_*), consistently across Rust + the bundled
  shell-integration scripts (both ends of the protocol move together).
- TERM_PROGRAM value WarpTerminal → ZaplexTerminal (+ its detection check).

Safe to blanket because WARP_ (uppercase) and WarpTerminal do not collide with
the lowercase warp_* crate names (provenance scheme, kept). cargo check -p warp
green. Runtime behaviour (shell integration/blocks/completions) needs a real run.
…n protocol

Runtime coverage (actually executed, not just type-checked) for the client half
of the daemon-hosted session protocol, via the existing tokio::io::duplex mock
harness:
- open_session / attach_session request-response (correct frame fields + parsed
  response).
- SessionOutput / SessionExited server pushes surfacing as ClientEvents.
- send_session_input / send_resize_session / send_detach_session fire-and-forget
  frames received correctly server-side.

Together with the server-side spawn_session_pty test (app local_tty/unix.rs, run
on the xl dispatch), both halves of the daemon-session mechanism now have
runtime coverage. `cargo test -p remote_server` → 71 passed.

Also fixes a pre-existing stale assertion: oss_binary_name_matches_zap_cli
expected "warp-oss" but the zap fork's binary_name() is "zap-oss" (matches the
test's own name + the neighbouring ~/.zap namespace test). It was never caught
because the CI gate runs cargo check, not cargo test.
Close the last headless-testable gap (Option 2): an end-to-end test of the
server-side glue on a real warpui test App, no GUI and no SSH. It drives
ServerModel::handle_message directly:

- OpenSession spawns a real PTY + shell and replies SessionOpened.
- SessionInput reaches that PTY; the background reader task streams the shell's
  output back as SessionOutput pushes through the model — asserted via a marker
  (`echo D4''EM0N` echoes verbatim but executes to `D4EM0N`, so the marker only
  appears in genuinely-executed output, proving the full input→PTY→output
  round-trip, not just terminal echo).
- CloseSession reaps the shell and emits SessionExited.

Builds the model via the existing struct-literal test_model() helper (so the
test needs no FileModel/RepoMetadata singletons) while still getting a real
ModelContext (executor + spawner) from App::test — the test App's background
executor is a real 1-thread tokio runtime, so the detached PTY reader runs for
real. Deadlines (async_io::Timer) guard against hangs. Unix-only.

Compile-verified (cargo check -p warp --tests). Runs on the xl test-dispatch
(cargo test -p warp daemon_session) — app-crate tests don't build on the dev host.
… drop)

The daemon session now outlives the client and replays missed output on
reattach — the core "survives the drop" payoff.

Server (S3a):
- handle_attach_session: replay_from(last_seq) from the session's ring, re-point
  the live stream at the reconnected connection, reply SessionAttached{size,
  base_seq, replay}. handle_detach_session: keep the session running (output
  buffers in the ring). ListSessions stays Stage 4.
- Grace-timer guard: when the last connection drops, only arm the daemon's
  shutdown grace timer if NO live sessions remain — persistent sessions keep the
  daemon up until reattach (was: daemon shut down after 10 min, killing them).

Server test (S3b, runs on xl): open a session, stream output, simulate a client
drop (deregister), produce more output while detached, reconnect on a fresh
connection, AttachSession(last_seq=0) — assert the replay contains both the
pre-drop and the while-detached output, then that live output re-routes to the
re-attached connection.

Client (S3c, daemon_tty): track last_seq from SessionOutput; on
SessionReconnected, attach_session(last_seq) and feed the replay through the ANSI
processor to reconstruct the grid. (Runtime validated via the GUI/real-host E2E;
re-establishing the headless ControlMaster on an SSH drop is noted in the spec.)

Design: docs/superpowers/specs/2026-06-28-stage3-attach-replay-design.md.
cargo check -p warp --tests green.
Make the daemon's sessions listable, the foundation for the multi-session
sidebar + adopt-by-id.

- Session metadata: each session now carries its cwd, shell, and last-attach
  timestamp (open counts as the first attach; refreshed on every AttachSession).
- Server handle_list_sessions: returns SessionList of SessionInfo over the
  registry (title derived from cwd basename else shell; alive == registry
  membership, since exited sessions are removed + announced via SessionExited).
  Wired into the dispatch (unix); non-unix rejects honestly.
- Client list_sessions() request/response method.

Tests:
- client list_sessions_round_trip (remote_server, runs locally — green: 72 passed).
- server list_sessions_reports_open_sessions (server_model_tests, xl): open two
  sessions in distinct temp cwds, assert both are listed with their cwds + alive,
  then closing one shrinks the list to the survivor.

Deferred to a follow-up (noted in the spec): detached-idle GC + ring ceiling as a
per-host setting (S4c), and the adopt sidebar (GUI E2E). The per-host
session_resilience setting + UI already shipped in Stage 3b.

Design: docs/superpowers/specs/2026-06-28-stage4-multisession-design.md.
cargo check -p warp --tests green (no warnings); cargo test -p remote_server green.
Memory governor for the daemon's persistent sessions:
- gc_sessions(now, max_detached_age, host_ring_cap): reaps sessions that are
  detached (no live attached connection) and either idle past the max age
  (default 24h) or, if total output-ring bytes exceed the host cap (default
  256 MiB), the oldest detached ones until back under the cap. Never reaps a
  session with a live connection. Wall-clock is injected so it's unit-testable.
- Periodic sweep (start_gc_timer, every 5 min) on the background executor,
  re-entering the model each tick; started from new() on unix.

Test (server_model_tests, xl): open two sessions, drop the connection (both
detached), then assert age-GC reaps the ancient one and keeps the recent one,
and a zero host cap reaps the remaining detached session once it has ring bytes.

cargo check -p warp --tests green (no warnings).
ensure_control_master no longer trusts a lingering socket file: it runs
`ssh -O check` and only reuses a master that is actually alive, otherwise it
removes the stale socket and spawns a fresh one. This is what lets a daemon
session's transport be re-established after an SSH drop — the session kept
running daemon-side, and a reconnect attempt now rebuilds a dead master instead
of failing against a stale socket.

Compile-verified. Full transport reconnect (manager interplay) is validated in
the GUI/real-host E2E.
…nning session

Back-end plumbing for the multi-session "adopt a running session" path (the
sidebar's action). DaemonSessionRequest gains adopt_pty_session_id: when set,
the daemon_tty event loop attaches to that existing session (replay + live via
the Stage 3 reattach path) instead of opening a fresh one. A new
on_transport_connected() opens-if-pending else attaches-if-adopted on connect.

Workspace gets a pub `adopt_daemon_session(server, pty_session_id, ctx)` entry
point that the sidebar calls (it builds the adopt-mode tab + drives the headless
connect). The session listing it presents comes from
RemoteServerClient::list_sessions (Stage 4 S4a).

Compile-verified. The sidebar UI (rendering the list + click-to-adopt) is the
GUI E2E; this is its complete back-end.
…bility

Phase B3 = swap the transport beneath the session protocol from SSH
ControlMaster + proxy-stdio to a native mosh-grade UDP datapath (roaming + low
latency). A full implementation is a large, networking-heavy subsystem that
cannot be meaningfully verified without a real lossy/roaming client-host link,
so this lands design + the only safe, additive footprint:

- FEATURE_UDP_TRANSPORT reserved in zaplex_remote_session::types — the
  negotiation name, explicitly NOT advertised by supported_features() (honest
  capability handshake; daemon never claims UDP until it's real).
- Design doc covering the model (SSH bootstrap → AEAD key → UDP), the
  RemoteTransport trait seam (UdpTransport alongside SshTransport, session layer
  unchanged), SSP-style delta sync over the existing seq cursor, roaming,
  predictive echo, the capability/feature gate, and an explicit remaining-work +
  verification list.

Design only — no UDP datapath shipped. B2 (Stages 2-4) remains the shipping
transport; B3 is the deferred upside, to be built + reviewed separately once the
real-host E2E has exercised B2.
…completions)

A daemon session is no longer a bare VT. After spawning the PTY, the daemon
writes the Zaplexify shell-integration init script as the session's first input
(via the ordered writer, ahead of any user input), so the remote shell gets
blocks, prompt marks, input modes, and completions — the actual premium-terminal
experience. The init script self-sets TERM_PROGRAM, emits the InitShell DCS hook,
and is idempotent (ZAPLEX_BOOTSTRAPPED guard), so a later re-attach won't re-run
it. Shells without a known ShellType (bash/zsh/fish) fall back to a plain shell
with a clear log. Required making terminal::bootstrap a pub module so the daemon
path (under remote_server) can reach init_shell_script_for_shell.

This closes the functional gap that previously made daemon sessions a bare remote
shell. cargo check -p warp --tests green.
…nect + diagnostics

Make the daemon connect robust + debuggable for the first real-host run:
- spawn_daemon_session_connect now, after the ControlMaster is up, checks the
  remote-server binary via the RemoteTransport trait and auto-installs it if
  missing (install_binary) before connect_session — so a host that's never been
  used no longer fails silently. (Preinstall-gate/legacy fallback is skipped: a
  daemon session has no non-daemon fallback anyway; a genuinely unsupported host
  fails with a clear error.)
- Diagnostic logging across the whole connect path (ControlMaster → binary
  check/install → connect_session → OpenSession → session opened → re-attach), so
  the first bring-up on a real host is traceable in the logs.

cargo check -p warp --tests green (no warnings).
…it tests, runbook

De-risk the first real-host bring-up with everything verifiable without a host:
- daemon_session_runs_zaplexify_bootstrap (server_model_tests, xl): opens a bash
  session and asserts `echo TP=$TERM_PROGRAM` yields TP=ZaplexTerminal — runtime
  proof that the daemon actually runs the Zaplexify integration script over the
  spawned PTY (the previously-unverified bootstrap mechanism). Also re-exercises
  the existing daemon_session tests now that every session is bootstrapped (no
  regression).
- headless_connect unit tests: is_headless_capable (key only), control_socket_path
  (stable + per-host), alloc_daemon_session_id (unique, top-half).
- Test runbook (docs/.../daemon-session-test-runbook.md): preconditions, expected
  client-log sequence, failure-mode table, and what to capture — so the bring-up
  is efficient.

Also confirmed by static trace (no code change needed): connect_session stores
the client + emits SessionConnected under our allocated session_id (daemon_tty's
wait resolves); resolve_server_auth maps Key + OneKey-key → AuthType::Key with
key_path (daemon path triggers, password falls back); session_resilience flows
DB → sidebar → open_ssh_terminal. cargo check -p warp --tests green.
…pawn

The daemon-spawned PTY went through spawn_session_pty, which set TERM/TERM
but never TERM_PROGRAM — unlike the local spawn (build_host_shell_command).
So a daemon session had no Zaplex terminal identity: TERM_PROGRAM was empty,
which the shell integration and plugins key off to recognize a Zaplex
terminal. The injected bootstrap *script* supplies shell integration (it
emits the InitShell DCS hook) but does NOT set TERM_PROGRAM — that belongs
in the spawn env. spawn_session_pty now sets TERM_PROGRAM=ZaplexTerminal,
COLORTERM=truecolor, and TERM_PROGRAM_VERSION/ZAPLEX_CLIENT_VERSION, each
honoring a client override via env (mirrors build_host_shell_command).

The pre-existing bootstrap test asserted only TERM_PROGRAM, which after this
fix comes from the env and not the script — so it would have passed even
with the script injection removed (tautological). Rewritten to pin BOTH
pieces independently: the InitShell DCS hook in the output (script ran) AND
TERM_PROGRAM=ZaplexTerminal (identity env). Caught by the pre-test xl run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The client-side daemon_tty EventLoop was only statically traced. These add
runtime coverage of its daemon-session-specific logic (the shared ANSI parser
itself is already covered by the terminal-model/ANSI tests, so we don't
re-test rendering):

- session_output_routes_to_terminal_and_filters_by_pty: drives the REAL path
  — a RemoteServerManager singleton emits SessionOutput, delivered synchronously
  via flush_effects to the loop's subscription. Asserts our session's output
  reaches the parser+model (repaint wakeup, fired after parse_bytes) and
  advances last_seq to seq+len (the replay cursor); a push for a foreign
  pty_session_id on the same connection is filtered out (no wakeup, last_seq
  unchanged); a contiguous follow-up chunk advances the cursor correctly.
- input_before_session_open_is_buffered_then_flushed: keystrokes before
  OpenSession resolves are buffered in order and drained once the pty_session_id
  is known — nothing typed during the connect window is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zap_release.yml builds all platforms (incl. ~6h Intel) and publishes a public
GitHub Release — too heavy for the iterative GUI test loop. This adds a
workflow_dispatch that builds ONLY the requested macOS arch via script/bundle
(--selfsign, ad-hoc) and uploads the .dmg as a workflow artifact, no release.
Mirrors the existing test-dispatch.yml pattern for Rust tests.

dmg_tag is baked as GIT_RELEASE_TAG so the remote-server path the client probes
on the target host (~/.zap/remote-server/zap-oss-<tag>) is deterministic and
the daemon binary can be pre-staged there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arget

The macOS DMG can't auto-install the daemon on the Linux SSH target (the
release install script downloads from zerx-lab/warp releases, which has no
asset for our fork's tag). So the linux remote-server binary is built here as
a static-musl artifact and placed manually on the target host at
~/.zap/remote-server/zap-oss-<tag>, where check_binary finds it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Xcode 26 ships the Metal toolchain as a separately downloadable component.
Without it `xcrun metal` is missing and crates/warpui/build.rs panics
compiling the .metal shaders ('cannot execute tool metal due to missing Metal
Toolchain'). prepare_environment selected Xcode 26 but never ran
script/macos/install_build_deps (which does xcodebuild -downloadComponent
MetalToolchain), so every macOS build failed — incl. zap_release.yml. Add the
step right after setup-xcode so it targets the selected Xcode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Use the prepared zaplex.icns directly as the macOS app icon (cargo-bundle
  copies a provided .icns as-is). The source zaplex-icon.png is RGB without
  alpha (black corners) so it isn't suitable as the icon directly; the .icns
  carries proper transparency + all sizes.
- Disable the inherited adaptive AppIcon.icon (old zap glyph) by renaming it so
  script/compile_icon skips it for the oss channel — otherwise actool would
  override CFBundleIconFile with the old icon. (A future polished adaptive icon
  would need a transparent >_ glyph SVG; the provided asset is a finished icon.)
- Replace the DMG installer background with the zaplex splash (700x500).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full release-lto build of the whole workspace on macos-26 (no sccache) takes
~60-120 min cold — unworkable for iterative GUI testing. Add a 'fast' input
(default true) that passes --debug to script/bundle → CARGO_PROFILE=dev
(unoptimized, no LTO) → minutes. The pre-staged daemon binary makes the
install path irrelevant, and debug_assertions only help catch bugs during
testing. Set fast=false for an optimized release-lto DMG when needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First real-host test surfaced the bug: connecting a daemon session to a host
whose login profile auto-launches byobu (e.g. ~/.profile sourcing
byobu-launch) made the daemon's login shell JOIN the user's existing byobu
session group — cross-contaminating I/O (the injected bootstrap script leaked
into the user's live session; the daemon tab mirrored it). This violates the
core design (the daemon owns persistence itself; it must not compose with the
user's multiplexer). Set BYOBU_DISABLE=1 in spawn_session_pty's env (byobu's
documented opt-out, honored by byobu-launch). Client may override via env.

Regression test: the bootstrap daemon_session test now also asserts the shell
sees BYOBU_DISABLE=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iret77 and others added 30 commits June 29, 2026 17:26
…llback

- Self-review finding: the shared per-host ControlMaster was spawned with
  ControlPersist=yes, so the backgrounded master never exited — and since daemon
  sessions no longer stop it on tab close (it's shared), it leaked one ssh master
  process per host, surviving even app exit (-f detaches it). Switch to
  ControlPersist=600 so it self-retires after idle while still being reused for
  reconnects / new tabs in the window. The remote daemon session is independent
  of the master and survives regardless.

- Codex review #6 (P1): the remote-server install script's tarball binary search
  dropped `zap-oss`, but the (deferred) release workflow still packages that name,
  so installs from a published OSS tarball would fail with "no binary found".
  Add `zap-oss` to the defensive fallback list (alongside the existing `warp-oss`
  legacy name), so the script accepts both the new `zaplex` and the legacy name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review (UI pass) of the new SSH-manager surfaces against the panel's
existing design-system patterns:

- Right-align trailing actions: the candidates header (Refresh) and candidate
  rows ("+"/"Added") used a fixed-width spacer + Start alignment, so the action
  floated mid-row. Switched to a left-group + SpaceBetween (the render_toolbar
  pattern), pinning the action to the right edge.
- Align adopt-session rows to the tree grid: the indent was ad-hoc and placed the
  session title left of its host's name; now derived from the same constants the
  tree row uses, so the title lines up under the host name.
- Consistent hover feedback: added the standard fg_overlay_3 hover background to
  the session rows, the candidates header toggle, and the blank/cancel buttons
  (previously only candidate rows highlighted).
- Session fetch errors now render in the theme error color (like the candidates
  error row) instead of muted gray; dropped the now-redundant glyph.
- Unified status-message font to ui_font_body (matching candidate messages).
- Standardized the small icon-button corner radius to 4 (was 3).
- Visual separation: small top margin before the ~/.ssh/config suggestions, and
  bottom padding under the whole add block so it reads as distinct from the tree.
- server_view: onekey type pills use Wrap::row (wrap on narrow forms) like the
  sibling auth/resilience/ring pill groups.
- Magic numbers -> shared constants (toolbar padding, row spacers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename branch added a bottom margin the normal (hoverable) branch didn't,
so a row nudged 2px when rename mode toggled. Match the normal branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…play gap, prune leaks

From an independent adversarial review of the daemon-session changes (it also
confirmed the round 4-6 fixes are correct):

- P2: a daemon-side failure *after* the transport connected left a blank/hung tab.
  The OpenSession and reattach result handlers only logged. Now both write a
  visible notice (and OpenSession clears pending_open), mirroring the pre-connect
  failure path. Covers: bad cwd / unspawnable shell / fd exhaustion on open, and
  the session vanishing in the race between listing and adopting.

- P2: replay-gap corruption after a long outage. If the daemon's OutputRing
  evicted bytes the client never saw, reattach applied the post-gap replay onto
  the stale grid (the evicted span may have held clears/cursor moves) → garbled
  terminal. Now, when base_seq > last_seq, reset the screen + scrollback and note
  the truncation before applying the replay.

- P3: persistent_session_ids wasn't cleared on reconnect exhaustion (slow,
  bounded id leak) — now removed in that terminal branch.

- P3: the SSH panel's per-host adopt-session maps (host_sessions, expanded,
  loading, error, row_states) weren't pruned when a node was deleted — now
  retained against the live node set in refresh_tree.

Deferred (noted): client-side pending_input cap during very long outages;
focus-existing-tab on duplicate adopt; resolve OneKey credential before offering
the inline Sessions list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urate OneKey gating

The three items deferred from the self-review:

- Bound client-side pending_input during long outages: consecutive resizes
  coalesce to the latest, and input past a 256 KiB cap drops oldest-first, so a
  laptop sleeping for hours can't grow the buffer without limit. Regression test
  added.

- Duplicate adopt now focuses the existing tab instead of opening a second view
  onto the same daemon session (which split input/output across tabs). The
  workspace tracks pty_session_id -> hosting tab (pane-group id), pruning stale
  entries opportunistically.

- The inline "Running sessions" list now resolves OneKey -> effective auth and
  shows a clear "needs key-based authentication" message for non-key hosts,
  instead of letting the headless list attempt run and surface a confusing ssh
  BatchMode error. (Removed the now-unused is_daemon_capable helper.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…output, OneKey listing

- P2: the warp_ssh_manager test suite no longer compiled (test SshServerInfo /
  SyncServer literals missing ring_ceiling_mb) and its in-memory schema helper
  stopped before the session_resilience + ring_ceiling migrations, so repository
  tests would fail with "no such column". I had only run the app crate's tests
  and missed this. Added both migrations to setup_in_memory and the field to the
  four test literals; `cargo test -p warp_ssh_manager` is green again (91).

- P2: fresh daemon sessions could lose initial shell/bootstrap output. The daemon
  auto-attaches and starts the PTY before the OpenSession response reaches the
  client, so SessionOutput arriving while pty_session_id was still None was
  dropped. Now buffer that output (bounded) and render it in order in
  on_session_opened. Regression test added.

- P2: the inline session list / adopt used the raw saved record for OneKey hosts.
  It resolved the credential only to *check* key auth, then passed the unresolved
  server to control_socket_path/list_daemon_sessions (and adopt) — wrong
  username/key_path → wrong ControlMaster / auth failure. Now build a resolved
  server (as open_ssh_terminal does) for both listing and adopting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review #8 (P1): I'd changed the OSS desktop entry's Exec to `zaplex`, but
the deb/rpm/arch packages still name the package `zap` (PACKAGE_NAME), so they
install the command as `zap` — the desktop launcher would fail. Match Exec to the
actual installed command (`zap`); the runtime-identity fields (StartupWMClass,
Icon, MimeType, Name) stay on the correct dev.zaplex.Zaplex / zaplex values. The
full Linux package-slug rename remains deferred (macOS-only product, no Linux CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a build & install preamble (aarch64 DMG via exports/, daemon binary
  pre-placed at ~/.zaplex/remote-server/zaplex-<tag>, tag v0.daemontest-0630).
- Add test steps for the now-built adopt-sidebar (list + re-attach, focus on
  duplicate adopt, key-auth gating), the on-demand add-host UX, the long-drop
  scrollback-reset notice, and visible failure notices.
- Fix the remote daemon log path (~/.zap -> ~/.zaplex).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…de + Codex)

Scopes the first increment of the cockpit (the "plex"): a native, read-only data
layer that discovers Claude + Codex accounts/subscriptions and aggregates per-account
token usage into rolling windows with cost + heat, exposed via a CockpitModel
singleton refreshed by file-watch. Grounded in the verified on-disk CLI footprint,
the claudeplex reference (Claude-only; Codex is net-new), and the existing
watcher/singleton patterns. UI, live session inventory, launch-on-freest, switching,
multi-host, and history are scoped as later increments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y up

Session persistence is a core zaplex feature (it's what makes a session survive
transport drops), so:
- New hosts default to PersistOnly (moved the enum #[default]); it only actually
  engages for headless-capable key-auth hosts, others fall back transparently.
  Existing saved hosts keep their stored value; the DB-load fallback and the sync
  legacy-payload default are pinned to Off explicitly so nothing is silently
  upgraded.
- The persistence toggle is moved from the very bottom of the host form to
  directly under the auth section (prominent), instead of buried after Notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bundled themes with background images (Phenomenon, Jellyfish, Koi, Leafy,
Marble, Pink City, Snowy, Red Rock, Dark City, Solar Flare) hurt text
readability and nobody uses them; the two Warp referral-reward themes are
out-of-scope cruft (no referral program in zaplex). All are removed from the
ThemeKind enum, the Display impl, and the built-in registry, along with their
now-orphaned color helpers/constants and imports.

Adds "Zaplex Dark" and makes it the default (compiled #[default]): a deep-navy
solid background with the blue->purple accent from the splash screen, using the
well-tuned Tokyo Night ANSI palette for readable terminal colors. The
onboarding theme picker and fallbacks point at it; the vestigial inherited
Adeberry A/B default-override in SettingsInitializer is removed (the compiled
default now already is the desired one). Gradient (non-image) themes such as
Cyber Wave, Willow Dream and Fancy Dracula are kept.

Note: PhenomenonStyle in warp_core (the internal UI design-system palette used
by callouts/onboarding/launch modal) is unrelated and untouched. Orphaned
jpg/*_bg.jpg assets remain for now (one test reads jellyfish_bg.jpg); asset
pruning is a separate cleanup.

Theme-editor follow-up tracked in #18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integrated file manager is a pane view-mode (terminal <-> files toggle,
connection-bound) rather than a fixed dual-pane MC or a sidebar tool. Specs the
PaneContext model, the FsBackend abstraction (reusing the existing SFTP backend
split), the cross-tab copy/move target registry with MC-speed default-target
heuristic, the transfer engine, UX for beginners+pros, and P1-P3 phasing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… template)

Self-contained cleanup, step 2 item 1. The toolbelt now leads with the
remote-dev core (SSH hosts -> Server Files), then local workspace tools
(Project, Global Search), then agent conversation history, then Skills — SSH is
no longer buried mid-list.

Zaplex Drive (inherited Warp Drive) is removed from the shipped toolbelt but
PRESERVED as a template: the ToolPanelView::ZaplexDrive variant, all its render
match-arms, and the drive/ + cloud_object/ modules stay compiled and revivable
for the later claudeplex agent/sidebar work. The three unwrap_or(ZaplexDrive)
active-view fallbacks now point at the always-present SshManager.

Approach + remove-vs-preserve classification recorded in
docs/superpowers/specs/2026-07-01-self-contained-cleanup-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two host-manager UX fixes from the test feedback.

A1 — Save button dirty-tracking. The host editor now snapshots every field the
Save button submits (all editors + auth type, resilience, ring ceiling, group,
onekey credential) into a baseline on load and after each successful save. Save
is enabled only while the form differs from that baseline; it re-baselines in
`reload` (which on_save already calls), so a successful save visibly disables the
button again — the "it worked" feedback that was missing. Disabled state uses a
muted background/text (not just a dead click) so it doesn't look clickable. The
per-editor edit listener now re-renders on every edit so the button updates live.

A2 — add-mode clarity. The "Add a host" block is wrapped in a card with an
accent left-bar + subtle background and an accent-colored heading, so it's
obvious you're in add mode; and the "no servers yet" tree empty-state is
suppressed while the add block is shown (the two together read as a
contradiction).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s templates)

Self-contained cleanup, step 2 items 3–4. Removes Warp/Oz from user-visible
surfaces without deleting the underlying (preserved-as-template) infrastructure.

Oz de-branding (text only; keys, Harness matching and cli_command_name
resolution untouched):
- 19 warp.ftl values neutralised ("Fix with Oz" → "Fix with AI"; "…by Oz" →
  "…by the agent"; "Install Oz CLI" → "Install Zaplex CLI"; "New Oz agent
  conversation" → "New agent conversation"; tip/onboarding/slash-cmd mentions;
  vertical-tab kind "Oz" → "Agent"; etc.). Fluent keys are unchanged so all t!()
  call sites keep resolving.
- The one release-visible hard-coded label — the invalid-settings banner's
  "Fix with Oz" button (shown when BYOP AI is enabled) — is now "Fix with AI"
  with the neutral Icon::AiAssistant instead of the Oz brand mark. (Its action is
  the repurpose target for a later increment.)

Zaplex Drive de-listing (code preserved as template):
- Removed the top-level "Drive" menu from the menu bar and the "Toggle Drive"
  item from the View menu; `make_new_drive_menu` is kept behind #[allow(dead_code)].
- Flipped the `enable_warp_drive` setting default to false — the documented master
  switch that also gates the Drive keybindings (flags::ENABLE_ZAPLEX_DRIVE) and the
  command-palette / command-search entries, so Drive no longer surfaces anywhere by
  default while drive/ + cloud_object/ stay compiled and revivable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f scope)

The `pricing` module modelled Warp's own SaaS subscription tiers (Stripe:
Business/Pro/Team/…) and pricing/addon-credits. It is out of scope for a
self-contained BYOP product and is NOT the basis for the cockpit's cost tracking
(which derives cost from the CLIs' own transcripts + a per-model LLM price table,
not Warp SaaS plans). It was already inert: `PricingInfoModel` had zero real
readers (only its singleton registration), and `StripeSubscriptionPlan` was used
by a single, uncalled `TryFrom<&BillingMetadata>` impl.

Removed the module, its singleton registration, the dead TryFrom impl in
workspaces/workspace.rs, and three test-only registrations. The workspace's own
`usage_based_pricing_*` fields (a separate, workspace-internal concept) are
untouched. Builds green incl. tests.

The other inert cloud stubs (request_usage/quota, drive ACL shells,
shared-session transport, telemetry) are much more deeply woven (16–219 files)
and are tracked for a separate, carefully-tested pass rather than removed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…CLI)

Specs how Oz's useful contextual one-shot actions are kept but routed to the
user's own installed CLI agent (Claude Code/Codex/Gemini/…) instead of an in-app
agent. Grounded in the real mechanism (CLIAgent + command_prefix +
is_cli_agent_installed; add_tab_with_specific_agent; the agent_sdk harness
command-builders). Defines an AskAgent primitive, the installed-set selection UX
(0/1/many), prefill-vs-one-shot delivery, and P1-P3 phasing. Flagship P1
retargets FixSettingsWithOz (the release-visible "Fix with AI" button) off the
flag-gated agent-mode and onto the user's CLI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(new crate)

New pure, headless-testable crate `zaplex_cockpit` — the read-only data foundation
for the cockpit ("plex" half of the product). Per
docs/superpowers/specs/2026-06-30-cockpit-increment1-account-usage-design.md.

- types: Provider (Claude/Codex), Account (metadata only), UsageEntry,
  WindowTotals (work = in+out+cache_create+reasoning, excludes cheap cache reads),
  AccountUsage, CockpitSnapshot.
- pricing: centralized per-model table (USD/1M, distinct cache rates), substring
  match, reasoning bills as output, unknown models cost 0 + logged (never silently
  mispriced). Seeded from current Anthropic + OpenAI list prices (flagged: refresh).
- windows: ccusage-style rolling blocks (5h / 7d) + calendar today, reset times,
  heat = work/budget; all pure with explicit `now` for deterministic tests.
- claude: $HOME account discovery (~/.claude + ~/.claude-* + $CLAUDE_CONFIG_DIR,
  backup dirs excluded; oauthAccount identity; plan-tier label) + defensive
  transcript parsing (assistant turns, counts only).
- codex: auth.json discovery (email from the unverified id_token JWT payload,
  tokens never stored) + defensive rollout parsing (per-turn last_token_usage,
  ignores cumulative envelope; schema best-effort per design §10).
- build_snapshot: single I/O entry point tying discovery + usage + aggregation.

Privacy invariant enforced structurally: types carry only token counts + account
metadata, never token strings or transcript content. 17 unit + 1 integration test,
green, warning-free (cargo test -p zaplex_cockpit). No app wiring yet.

Architecture note: kept the crate warpui-free (pure/fast to test); the
CockpitModel + file-watch wiring will live in app/src/cockpit/ (deviates from the
doc's "warpui in the crate" for testability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h + settings

Wires the pure zaplex_cockpit data spine into the app (app/src/cockpit/):

- CockpitModel: SingletonEntity holding the latest CockpitSnapshot, emits
  CockpitEvent::Updated. Refresh is driven by (a) HomeDirectoryWatcher for account
  add/remove and (b) a 45s reconcile tick for transcript growth + window rollover
  (the non-recursive home watcher can't see appends deep in projects/**/sessions/**).
  The blocking disk scan runs on the background executor; results apply on the model
  thread via the spawner round-trip — mirroring file_mcp_watcher + the daemon GC timer.
- CockpitSettings (scalar): cockpit.enabled (gates refresh), cockpit.budget_5h /
  cockpit.budget_week overrides (0 = built-in estimate). Registered in settings init.
- Registered as a singleton in lib.rs after HomeDirectoryWatcher exists.
- Manifests: zaplex_cockpit added to workspace + app deps.

Deviation from the design doc (for testability): the data logic stays in the
warpui-free crate; only this thin model/watch layer lives in the app. Compiles
clean (cargo check -p warp, 0 warnings); crate tests green. Runtime behaviour of
the watcher/tick will be confirmed in the next build. No UI yet (Increment 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ex way)

Reframes the cockpit port: claudeplex wraps a cockpit around EXTERNAL infra
(claude CLI + tmux fleet + SFTP commander), all of which zaplex already owns
natively (pane multiplexer, persistent session daemon w/ ring ceiling, SSH
manager + FM pane-mode, CLI agents, conversation history, themes, i18n, palette).
So the cockpit is the missing INTELLIGENCE/ORCHESTRATION layer, not the infra — a
lens over zaplex's existing panes/daemon-sessions/hosts, never a parallel world
(the anti-foreign-body principle, mirroring the FM pane-mode adaptation).

Includes a full parity checklist (every claudeplex feature -> its native zaplex
home, nothing dropped; Codex is net-new = more than claudeplex), three native
surfaces (Cockpit pane dashboard, live inventory = zaplex sessions, ambient
attention indicator), native actions (account-routed launch / launch-on-freest,
daemon adopt, commander), and a revised native-first increment plan C1-C5 that
folds in the already-built data spine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three glance tiers: toolbelt-icon attention badge (ambient) · sidebar toolbelt
tab (quick-access while working, no main-area switch) · main-area pane (roomy
overview/deep-dive). Revised C2 to build both surfaces spine-backed, sidebar
first (approved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Cockpit sidebar (left toolbelt tab, first/leading per the approved
main-pane-vs-sidebar principle): a compact, glanceable account list over the
zaplex_cockpit data spine, subscribing to CockpitEvent::Updated.

- crate: pure `format` module (HeatLevel bands + reference palette, heat fill/pct,
  cost, humanized tokens, reset countdown) — 6 headless tests. Shared by sidebar +
  (later) pane.
- app: `CockpitPanel` view — per-account compact card (label + plan badge, 5h heat
  bar coloured by band, today cost+tokens, 5h/wk reset countdowns) + aggregate
  header (accounts, total 5h/wk cost) + empty state; vertically scrollable.
- Wired as `ToolPanelView::Cockpit` toolbelt tab (Icon::Grid), gated by
  cockpit.enabled, leading the toolbelt in compute_left_panel_views; full
  left_panel dispatch (button/focus/action/body), LeftPanelDisplayedTab
  persistence, and the single-view tooltip matches. i18n keys added.

Compiles clean (cargo check -p warp, 0 warnings); crate tests green. Live-session
quick-list + "needs-you" marker + quick-launch land in C3/C4; per the design its
visual layout is confirmed in the next build. The roomy full pane is C2b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Use Oz to update this config" -> "Use AI to update this config" (the
/update-tab-config footer hint), the last user-visible Oz string found in
testing. Trivial literal change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SH fallback

Root cause of the "Starting…" hang (found by testing, verified not is_dev_source_build):
persistence-default-ON routes every key-auth host through the daemon path, which,
when the daemon binary is absent for the build's tag (fresh host, or a tag mismatch
like devhost's zaplex-v0.daemontest-0630 vs expected zaplex-v0.daemontest), ground
through an unbounded install-on-connect (GitHub download for a non-release tag + a
194 MB scp) that only failed after ~3 min — appearing as an eternal "Starting…"
with an empty tab head.

Fix (keeps persistence as the default; degrades gracefully, never hangs, never lies):
- headless_connect::prepare_daemon_transport: when the binary is missing, fail FAST
  with the DAEMON_BINARY_MISSING sentinel instead of an install-on-connect. (Installing
  the daemon becomes a separate, explicit action — not a multi-minute stall per connect.)
- try_open_daemon_ssh_terminal: PREFLIGHT (ControlMaster + fast binary check) OFF the
  main thread BEFORE creating any tab. Only on success do we open the daemon tab and
  connect; on ANY failure we fall back to a classic local-PTY SSH session
  (open_ssh_terminal force_classic=true) AND raise a prominent persistent toast warning
  that there are NO persistent-session features and a disconnect loses open work.
  No more dead "Starting…" tab.

Compiles clean (cargo check -p warp, 0 warnings). Runtime behaviour to be confirmed
in the next build. Deploying a matching-tag daemon binary (to exercise the *persistent*
path itself) is a separate step; the fallback covers its absence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on connect

Corrects my earlier over-correction (which disabled auto-install). Per the approved
design: persistence must auto-install the daemon on first connect (like Warp), but
without ever requiring the *remote host* to reach the internet.

- install_binary: the PRIMARY path is now the client-push install (scp_install_fallback):
  detect the host platform, fetch the matching binary ON THE CLIENT, scp it over the
  existing ControlMaster. The host-side download (run_install_script(None)) is retired —
  remote-dev hosts are often locked-down / air-gapped. (dev-source builds still
  cross-compile locally.)
- prepare_daemon_transport: binary missing → auto-install via that client-push path
  (bounded by download + scp timeouts), then connect. On genuine install failure the
  caller still falls back to a classic SSH session with a warning (never a silent hang).

Compiles clean (0 warnings). NOTE: still needs a binary *source* to actually deliver —
a published per-platform release tarball (step 2) or an embedded common-platform binary
(step 3). Until then, a source-less host correctly + quickly falls back to classic SSH.
Progress-bar UI during install is step 1b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approved 4-rung ladder: reachability probe → host-side download → client relay
(embedded binary → else client download) → classic SSH + banner. Host never needs
internet; any host platform; version-matched (no skew); progress on every rung;
graceful classic fallback. Embeds common Linux musl binaries in the .app for the
offline case. Records the plumbing per layer + the CI/packaging step + build order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements rungs 1–3b of the approved install ladder (design doc
2026-07-02-daemon-install-ladder-design.md):
- Rung 1: probe_host_internet — a fast HEAD (curl -fsI --max-time 3) over the
  ControlMaster so a locked-down/air-gapped host doesn't pay a full download
  timeout before we relay.
- Rung 2: host reachable → host-side download (run_install_script, the host fetches
  the version-matched tarball on its own pipe — fastest). On failure → relay.
- Rung 3b: client relay — client fetches the host-matching binary and scp's it over
  the ControlMaster (host needs no internet). (scp_install_fallback.)

Rung 3a (bundled/embedded binary for the fully-offline case) + the progress-bar UI
+ the CI step that stages the musl binaries into the .app are the next chunks toward
one testable build. Classic-SSH+warning fallback (rung 4) already in place. Compiles
clean (0 warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mote-server

Root cause (found, not guessed): connecting any key host stalled at "Starting shell …"
because the remote-server SSH enhancement (`SshRemoteServer`/`ServerFileBrowser`) is
force-enabled even though zaplex has **no way to provide the host binary yet** (no
published release tarballs, no embed, and the packaged DMG is not a dev cross-compile
build). With the flag on but no source, every *classic/legacy* SSH session is routed
through a stash-and-install (model_events.rs:92) that **withholds the interactive shell**
until a doomed install finishes 404-ing through the download/scp timeouts. Both reported
hosts hit this: agenthost via the classic path directly, devhost via daemon→fallback→
classic.

Fix — make capability honest so nothing engages a doomed install:
- `enabled_features()`: drop `SshRemoteServer` + `ServerFileBrowser` unless an install
  source actually exists (`is_dev_source_build`; extend with an embed check when ladder
  rung 3a lands). No source → classic SSH is a normal **instant plain shell** again
  (no stash, no install, no banner). The persistent *daemon* path does NOT gate on these
  flags, so resilience is unaffected.
- Daemon path (`prepare_daemon_transport`): before auto-installing on first connect, a
  fast bounded `SshTransport::install_source_available()` (dev build, or a client-side
  `curl -fsIL --max-time 3` HEAD of the version-matched release asset). No source → return
  `DAEMON_BINARY_MISSING` in seconds → classic-SSH fallback + warning toast, instead of a
  multi-stage install stall. When the embed / a real release process lands, this same gate
  opens and becomes Warp-style auto-install-on-first-connect.

Net: both hosts now connect fast — a working shell every time, with a clear "no persistent
session" warning when the daemon can't be installed. No regression to the daemon path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The classic-SSH fallback warning went onto `update_toast_stack`, whose overlay is
only rendered behind `FeatureFlag::AvatarInTabBar` — hard-disabled in the fork's
decentralized branch (warp_features is_enabled always-false list). So the "no
persistent session — a disconnect loses open work" warning never appeared in any
test build. Move it to `toast_stack`, which renders unconditionally via
global_toast_positioning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…with progress

The full Warp-style persistent-session experience: connecting a resilient Linux
host now auto-installs the zaplex remote-server on first connect — offline, with
progress in the tab — and opens a persistent daemon session. Previously every
connect degraded to classic SSH because no binary source existed.

- Embed (install ladder rung 3a): CI builds the static musl Linux x86_64
  remote-server (same GIT_RELEASE_TAG as the client → version-matched by
  construction), packs it in the release-asset tarball layout, and stages it
  into the .app (Contents/Resources/bundled/remote-server/). Runtime resolver:
  app/src/remote_server/embedded.rs. test-dmg.yml gains a build_remote_server
  job; the DMG job stages the tarball via resources/bundled/ before bundling.
- setup.rs: tarball_basename() — single source of truth for the asset name,
  shared by download URL, embed resolver, and CI packing.
- Install ladder restructured (ssh_transport.rs): detect platform once →
  rung 1 probe → rung 2 host download → rung 3a bundled relay → rung 3b client
  download relay; shared relay_tarball_install(); InstallProgress phase channel.
- Progress in the daemon tab: preflight (fast, pre-tab) now classifies
  Ready/NeedsInstall (headless_connect::preflight_daemon_transport); on
  NeedsInstall the tab is created first and the install ladder streams phase
  lines into it (DaemonSessionRequest.install_progress_rx → daemon_tty event
  loop → dim-cyan notice lines). On success the session connects seamlessly;
  on failure the tab shows the error and a classic SSH session opens alongside
  with the persistent warning toast.
- Source gates re-widened: an embedded tarball now counts as an install source
  (SshRemoteServer/ServerFileBrowser flags + daemon auto-install) — the
  "source-honest" gating from ccf96bc opens up exactly when a source ships.
- Workspace: connect_daemon_session()/fall_back_to_classic_ssh() helpers
  replace the duplicated connect/fallback blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant