Skip to content

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

Draft
iret77 wants to merge 54 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 54 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 24 commits June 28, 2026 21:50
Finish the incomplete rename day for the OSS/zaplex product surface:
- app display name + AppId + bundle name/identifier/copyright -> Zaplex
- DMG volname + installer background asset (warp_install_image.png ->
  zaplex_install_image.png, now the zaplex splash)
- 507 files: standalone product 'Zap' -> 'Zaplex' in display strings,
  comments, and standalone identifiers (compiles clean; symbol defs+uses
  renamed together)
- URL scheme value for the build script (zap -> zaplex), translated an
  inherited Chinese comment

Deliberately NOT touched (per provenance convention + scope):
- crate names warp_*/zap_* and zerx-lab/warpdotdev URLs
- compound internal type names (ZapDrive, ZapLaunchModal, ZapAI, ...)
- lowercase structural daemon names (zap-oss binary, ~/.zap dir) — next commit,
  coupled to the daemon path + needs a coordinated rebuild/re-deploy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the product rename for the structural surface (was deferred):
- CLI/channel name + bin target: zap-oss -> zaplex (bin file zap_oss.rs ->
  zaplex.rs, Cargo [[bin]]/default-run/bundle-metadata key)
- config/data + remote-server dir: ~/.zap -> ~/.zaplex (paths.rs, setup.rs)
- URL scheme + AUMID/identifier: zap -> zaplex, dev.zap -> dev.zaplex
- logfile zap.log -> zaplex.log
- updated paths_tests / setup_tests expectations accordingly

This changes the daemon install path to ~/.zaplex/remote-server/zaplex-<tag>
and the bin name, so it takes effect only with the next coordinated build
(daemon re-deploy + DMG). The currently deployed old pair (zap-oss DMG +
~/.zap daemon) stays consistent and keeps working until then.

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

The broad product-name sweep changed two *functional* string literals in
project_dirs_for_app_id (not just display): starts_with("Zap") and the
replace target. That broke the suffixed-channel -> dashed-dir mapping
(ZapDev -> zap-dev), caught by paths_tests. Reverted those to "Zap" (the
detection prefix), kept the exact OSS arm. Also unified the AppId organization
from "zap" to "zaplex" everywhere so macOS/Windows project paths read
dev.zaplex.* consistently with the bundle identifier; updated test
expectations. warp_core (84) + remote_server (72) lib tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues from the first real-host test:

1. Connect failures showed a blank/hung tab (only logged). daemon_tty now
   renders a visible '[zaplex] connection failed (<phase>): <error>' notice
   into the terminal on SessionConnectionFailed, and a 'session ended' notice
   on SessionExited, via the normal ANSI path (bold red). Threads phase+error
   from the manager event. Added a regression test.

2. Clicking 'Save' in the SSH host form showed no feedback: the StatusBanner
   'Saved.' was set by on_save, but the Save click blurs the focused field and
   the Blurred handler immediately cleared the status. Removed the status clear
   from the Blurred arm (kept selection clearing); status is now cleared only
   when the user actually edits a field. Made the confirmation clearer ('✓ Saved').

Client-side; takes effect with the next coordinated build.

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

Foundation for the adopt-sidebar (multi-session UI). Extracts the daemon
connect-prep (ensure ControlMaster + check/auto-install remote-server binary)
out of Workspace::spawn_daemon_session_connect into a reusable
headless_connect::prepare_daemon_transport, so the sidebar's connect-to-list
path can share it with the terminal path (DRY, no behaviour change). Adds the
design doc capturing the architecture (Workspace orchestrates connect-to-list
since HostId isn't derivable from the saved server; panel renders + dispatches
adopt) and the increment plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-session UI in the SSH-manager sidebar. Right-click a host -> 'Running
sessions' fetches the host's live daemon sessions via connect-to-list
(headless_connect::list_daemon_sessions: ControlMaster + transient connect +
initialize + list_sessions + teardown), so it surfaces sessions that survived
an app restart / transport drop (no open terminal needed — the main use case).
Sessions render as inline child rows under the host (loading / error / empty
states); clicking one adopts it (attach + replay) in a new tab via the existing
Workspace::adopt_daemon_session, routed panel -> left_panel -> workspace
(mirrors OpenSshTerminal). Gated to key/onekey + session_resilience hosts; the
whole fetch is cfg(unix).

Compiles clean (warning-free). GUI rendering + the real connect-to-list need
visual + real-host verification in the next coordinated build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames the inherited internal Rust type/symbol names (ZapDrive, ZapDriveObject*,
ZapLaunchModal*, ZapAI, ZapNewSettingsModes, ZapSetup, …) to the Zaplex prefix
across 63 files — definitions + all usages together, so it compiles clean.

Deliberately excluded:
- ZapDev: an inherited *channel name string*, entangled with the
  starts_with("Zap") suffixed-channel mapping in paths.rs (renaming it would
  break that logic) — and not an internal type.
- ZapDockTilePlugin: a macOS dock-tile-plugin *bundle artifact* name spanning
  Info.plist + script/macos/bundle; renaming needs coordinated macOS-bundle
  changes that can't be verified here.
- crate names warp_*/zap_* and zerx-lab URLs (provenance convention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Translates the inherited Mandarin *comments* across ~30 files (build/release
scripts + workflows, remote-server install/preinstall scripts, and Rust source
comments incl. keyboard.rs IME key notes) to English, via small-model agents
with strict rules (comments only; code/strings/identifiers/URLs untouched;
markers + indentation preserved). 633 -> 332 CJK lines; shell scripts pass
bash -n, workflows parse, cargo check green.

Remaining CJK (332) is in STRING LITERALS, not comments: user-facing UI labels
/ error / log messages (should become English — a separate, more careful pass)
and CJK test fixtures (must stay, they exercise CJK handling). See follow-up.

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

Second i18n pass — the STRING LITERALS the comment pass left (the codebase is a
fork of the Chinese-origin zerx-lab/zap). Via small-model agents with strict
rules, translated to English:
- user-facing UI labels (SFTP 'File Manager', table headers, …) + error / log /
  diagnostic / panic messages across sftp/ssh/settings/terminal/workspace/auth/
  remote_server
- AI prompt text + the  tool JSON-schema descriptions, incl. dropping the
  inherited 'ask the user in Chinese' instruction (there were real Chinese AI
  prompts — now English)
- test assertion/failure message strings + #[ignore] reasons
- the matched sentinel '(tool 执行结果未保留)' -> '(tool result not retained)',
  changed consistently across all 4 occurrences first (it's compared at runtime)

Kept (legitimately): CJK *test fixtures* that exercise CJK/unicode/IME/multibyte/
width handling (unescape, crypto, completer paths, IME marked-text, multibyte
secret redaction, …) and one functional regex in su_password_injector that
detects Chinese password prompts. 633 -> 175 CJK lines (remaining = those
fixtures). cargo check (app + all crate tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an optional per-host "Scrollback buffer" setting (ring_ceiling_mb)
that bounds the daemon's OutputRing replay buffer. The client resolves
the host's configured value to bytes and passes it via OpenSession;
the daemon clamps to HOST_RING_CAP_BYTES and falls back to the default
ceiling when unset (0 = Default).

- proto: OpenSession.ring_ceiling_bytes (optional)
- daemon: clamp + apply in handle_open_session
- client: open_session(..., ring_ceiling_bytes)
- data layer: SshServerInfo.ring_ceiling_mb + migration + repo/sync
- UI: preset pills (Default/64MB/256MB/1GB) under session resilience

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

The saved host list previously rendered an always-on "Candidates" section
(every ~/.ssh/config host) above the tree whenever auto-discovery was on
(the default). Testers saw their existing connections appear as if pre-added,
even though nothing was actually persisted — confusing and out of the user's
control.

Now the list shows only what the user deliberately added. The toolbar "+"
opens a guided "Add a host" block (a prominent "Create a blank server" action
plus the on-demand ~/.ssh/config suggestions). Picking a suggestion still
imports explicitly; nothing enters the saved list automatically. ~/.ssh/config
is read only when the block is opened — never unsolicited on mount.

The auto-discovery setting now governs whether suggestions appear in the add
block (label/description updated); the storage key is unchanged.

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

Three defects found by codex review of the daemon-session path:

- P1: a pre-connect setup failure (ControlMaster bring-up / binary install)
  only logged, leaving the already-created daemon tab hanging on a blank view.
  Add RemoteServerManager::fail_session to emit SessionConnectionFailed so the
  tab renders the error via the existing on_connect_failed notice path.

- P2: once a daemon session was open, input arriving while the transport was
  down (the reconnect window this feature exists to survive) was dropped in
  dispatch_message. Now it is buffered and flushed on reattach, so keystrokes
  and resizes typed during an SSH blip are not lost.

- P3: the daemon could linger forever — deregister_connection skips the grace
  timer while sessions exist, and the GC reaping the last session never
  re-evaluated idleness. Add maybe_arm_grace_after_gc so the daemon retires
  once GC empties it with no connections.

Regression tests added for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
codex review #2 found the rename was only partially propagated into the build
scripts, breaking the OSS run path and packaging:

- script/run: ran `cargo run --bin zap-oss`, but the OSS bin is now `zaplex`.
- script/macos/run: pointed at `Zap.app` and URL scheme `zap`; cargo now bundles
  `Zaplex.app` and the OSS scheme is `zaplex` (ChannelState::url_scheme).
- app/channels/oss: bundle_install resolves `app/channels/oss/<BUNDLE_ID>.desktop`
  and BUNDLE_ID is now `dev.zaplex.Zaplex`, but only `dev.zap.Zap.desktop` existed.
  Renamed + fixed Name/StartupWMClass/Icon/MimeType to the new bundle id + scheme;
  translated the inherited Chinese comment.
- windows-installer.iss: OSS PATH helper was `zap-oss.cmd`; the OSS CLI command is
  `zaplex` (Channel::cli_command_name). Fixed + translated two now-incorrect
  Chinese comments.

The DMG CI path (script/bundle) was already correct. The deb/rpm/arch package
slug ("zap") and Windows installer UI strings remain on the old name; zaplex
ships macOS-only with no Linux/Windows CI, so a full cross-platform package
rename is deferred rather than landed untested.

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

Two findings from codex review #3:

- P2: the daemon SSH path skipped the host's `startup_command`. For saved hosts
  with a startup command, opening via the resilient daemon path silently ran
  nothing, unlike the local-PTY SSH path. Now OpenSessionParams carries
  startup_command and the daemon event loop runs it once after the session opens
  (sent as input + newline, the same way the daemon injects its bootstrap;
  `take()` ensures it never re-runs on reattach). Regression test added.

- P2: build-remote-server.yml staged the binary as `zap-oss`, but the client
  looks for `~/.zaplex/remote-server/zaplex-<tag>` (binary_name()=zaplex). Staged
  under the expected name + fixed the stale doc paths.

Also fixed adjacent rename leftovers the same gap implies: pr-check.yml ran
`cargo check --bin zap-oss` (the bin is `zaplex` now — would fail CI), and the
test-dmg.yml doc path. The full multi-platform release workflow (zap_release.yml)
still references the old name; it points at the upstream repo and isn't the
fork's release path, so its rename is deferred.

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

Three lifecycle defects in the daemon-session core:

- P1 (critical): daemon transport drops never reconnected. The generic
  connect path treats a transport-child exit as a terminal disconnect and
  skips reconnect — but for a daemon session the ssh/proxy slave exits on a
  network blip while the remote daemon keeps the PTY alive. Track persistent
  (daemon) sessions (mark_session_persistent) and, for those, still attempt
  reconnect on child exit, so the self-healing ControlMaster +
  SessionReconnected/reattach path actually fires. Without this the whole
  "survives the drop" feature was inert.

- P2: closing a daemon tab leaked the local connection. The daemon
  TerminalManager now implements on_view_detached and, on a permanent close,
  deregisters the connection (drops the per-session ssh/proxy child + manager
  bookkeeping). The remote session is left running (detached) for re-adopt, and
  deregister_session gained a stop_control_master flag so the per-host *shared*
  ControlMaster is preserved for sibling tabs.

- P2: a late DetachSession from an old tab cleared the attachment of a newer
  tab that had adopted the same session. handle_detach_session now only clears
  when the caller's conn_id still owns the attachment. Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ct heal, dead link

- P2: Connect ignored unsaved Persistent/scrollback choices. on_connect built
  SshServerInfo from the last-saved server for session_resilience/ring_ceiling_mb
  while using live editor state for everything else, so toggling Persistent and
  clicking Connect (without Save) silently did nothing. Now uses editor state.

- P2: persistent reconnect couldn't recover a dead ControlMaster. attempt_reconnect
  reuses the stored SshTransport, whose connect() did not re-run ensure_control_master
  — so if a network drop killed the shared master, every retry failed even though the
  daemon session was still attachable. SshTransport now opts into self-healing
  (with_self_heal) and re-establishes a stale master in connect(). unix-gated to match
  the headless_connect module.

- P3: the Zaplexify settings "Learn more" link had an empty href (broken no-op nav,
  a Warpify->Zaplexify rename leftover). Dropped the link until a real docs URL exists;
  the description reads as a complete sentence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…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>
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