Remote tmux durability: survive SSH drop, discover/attach, collaborative sessions#156
Conversation
COD-104 wired checkRemoteTmuxAvailable into the remote-session create path,
but it does a real `ssh` via exec — so 2 remote-create tests in
session-routes.test.ts hit a ~10s ssh timeout and failed (422). Mirror
TmuxManager's IS_TEST_MODE no-op-shell-under-VITEST: short-circuit the live
probe to {ok:true} under vitest. Command construction stays covered by
buildRemoteTmuxCheckCommand unit tests. session-routes.test.ts now 61/61.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6ae2c0b8160090a1f0f6b32a3fe8496d402ac2c6)
…ill)
Phase 2 of the remote-tmux arc. Discover codeman-* tmux sessions already
running on a remote host (created by the remote's own Codeman or another
instance) and attach to one this Codeman didn't launch, with detach-not-kill
ownership for non-owned sessions.
- remote-hosts.ts: listRemoteCodemanSessions (ssh, VITEST-guarded, never throws)
+ pure parseRemoteSessionList + buildRemoteListSessionsCommand. Parser splits
on the LITERAL \t the remote tmux emits (next-3.7 does not expand \t) AND a
real tab. toAttachedSessionRemote builds a non-owned SessionRemote; toSessionRemote
now marks the COD-104 launch path owned:true.
- tmux-manager.ts: buildRemoteAttachCommand (sibling of buildRemoteLaunchCommand);
buildRemoteSessionCommand selects attach vs launch by ownership. killSession gains
a detach-not-kill early return for non-owned remote sessions: tears down only the
LOCAL pane (kills local ssh -> remote attach detaches), NEVER issues a remote
kill-session.
- types/session.ts: RemoteSessionInfo; SessionRemote.owned + remoteSessionName.
- schemas.ts: CreateSessionSchema.attachRemoteSession {hostId, remoteSessionName};
fixed a pre-existing no-useless-escape lint error in the jumpHost regex.
- case-routes.ts: GET /api/remote-hosts/:hostId/sessions (explicit discovery).
- session-routes.ts: attachRemoteSession create path -> non-owned session.
- UI (index.html/session-ui.js/styles.css): explicit "Discover existing sessions"
button + Attach action (owned:false). No auto-discover.
Verified on aa-desktop: discovered codeman-disco1, attached (attached=1, shared
view), killed local probe pane -> remote SURVIVED_DETACH (attached=0). Tests:
parse/attach-cmd/ownership unit + discovery route, session-routes + case-routes green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524)
…to remote) Since COD-104 a remote session lives in a durable tmux server on the host and outlives the local pane, so killing a tab only DETACHED — even for sessions we own. Propagate `kill-session` to the remote for OWNED sessions in killSession's owned path (after COD-105's non-owned detach-only early-return); non-owned detach-only is untouched. Reconciled onto upstream PR Ark0N#145: Ark0N#145 already upstreamed this exact owned-kill propagation as `buildRemoteKillCommand({ remote, sessionId })` on the dedicated `-L codeman-remote` socket (matching buildRemoteLaunchCommand) and wired it into killSession (Strategy 3b, owned-only, fire-and-forget). The original COD-109 commit added a second `buildRemoteKillCommand(remote, name)` overload on the old `-L codeman` socket plus a duplicate kill block — a compile error AND a wrong socket post-Ark0N#145 (owned sessions no longer live on `codeman`). This commit keeps Ark0N#145's socket-correct implementation and drops the duplicate; the required test/remote-kill-command.test.ts is retargeted to Ark0N#145's `{ remote, sessionId }` signature and the `codeman-remote` socket. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continuous remote-only reconnect watcher closing the COD-104 durability arc: when a remote session's local ssh pane dies mid-run, re-establish it automatically instead of leaving a dead pane until the user pokes it. Design decisions (per cod108 design doc): - D1 event->owner: TmuxManager watcher DETECTS a dead remote pane and emits `remoteSessionDropped`; the session owner (server) reassembles the same RespawnPaneOptions and calls Session.reattachRemote() -> respawnPane, which re-runs the idempotent remote command (owned new-session -A / non-owned attach) and REJOINS the still-running durable remote tmux session. The watcher never reassembles options itself, and never routes through the Claude-idle respawn-controller. - D2 bounded backoff: per-session exponential backoff [5s,15s,45s,2m,5m,5m], reset on a successful reattach, `remoteReconnectExhausted` emitted once after the cap. Pure, unit-tested schedule + eligibility decision. - D3 always-on + kill-switch: `remoteAutoReconnect` app setting (default ON), read each tick; when false the watcher does nothing. Guards: killSession() (incl. the non-owned DETACH early-return) and shutdown add the session to an intentional-teardown guard set + clear its backoff BEFORE teardown, so a closed/killed tab is never auto-revived. Exactly one reconnect in flight per session (inFlight guard prevents stacked respawns). Per-session reconnect/guard state cleared on session removal. New: src/remote-reconnect.ts (pure backoff + decideReconnect), TmuxManager startRemoteReconnectWatcher/stop + runRemoteReconnectTick + noteRemoteReconnect + guardRemoteReconnect + clearRemoteReconnectState; Session.reattachRemote() (+ extracted _buildRespawnPaneOptions, shared with interactive start); server wiring + watcher start; 3 SSE events (sse-events.ts + constants.js in sync, broadcast + app.js exhausted "Reconnect" affordance); remoteAutoReconnect schema + settings-ui toggle. Tests: test/remote-auto-reconnect.test.ts (21) - pure schedule, eligibility (guarded never reconnects, non-remote/pane-alive/not-due skip, over-cap exhaust), and manager-level integration (dead remote pane -> dropped -> backoff -> exhausted; guarded emits nothing; reset-on-success; kill-switch off; state-cleared-on-remove). Verified real-remote against aa-desktop: drop local ssh pane -> watcher emitted -> respawnPane reattached the SAME remote session (remote pane_pid unchanged 3939->3939); test session cleaned up, the real host sessions left untouched. Checks: tsc, eslint, check:frontend-syntax, check:public-assets, prettier --check, build all green; tmux-manager/session-routes/session-manager/ sse-registry-parity suites pass. (cherry picked from commit d13d58b1994eb6594fd2eadea208104d36204f9d)
… + shared badge) Two Codeman clients attaching the same durable remote tmux session at different viewports would fight: tmux sizes a window to the SMALLEST attached client by default. Push `window-size latest` to the remote session config so the window tracks the most-recently-active client instead, letting concurrent clients coexist; surface the client count for a "shared · N" badge. Reconciled onto upstream PR Ark0N#145: Ark0N#145 moved the durable remote session onto the dedicated `-L codeman-remote` socket under a `codeman-ssh-` name and scoped every tmux set-option PER-SESSION (`set -t <name>`, never `-g`) so a shared remote tmux server's OTHER sessions keep their own prefix/mouse/sizing. The original COD-106 commit added `set -g window-size latest` (GLOBAL) on the old `-L codeman` socket — a regression against Ark0N#145's hardening. This commit layers the window-size feature onto Ark0N#145's structure as `set -t <name> window-size latest` (per-session, on the codeman-remote socket). Test assertions updated to the per-session form (remote-shared-sessions.test.ts) and the byte-identical launch-command test (remote-ssh-options.test.ts) extended with the window-size line — which supersedes the separate f09323c9 assertion fix (dropped: it targeted the global form and also carried unrelated CLAUDE.md doc changes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-sessions.md 55f5ada (COD-105) added Phase 2 of the remote-tmux arc: discover codeman-* sessions on a host and attach to non-owned ones, with detach-not-kill on close. - Data model: SessionRemote.owned/remoteSessionName + RemoteSessionInfo; toSessionRemote (owned:true) vs toAttachedSessionRemote (owned:false). - New Ownership section: discovery (listRemoteCodemanSessions, the literal-\t parse quirk, never-throws/VITEST), attach-vs-launch selection (buildRemoteSessionCommand), and the killSession detach-not-kill guarantee. - API: GET /api/remote-hosts/:hostId/sessions + the attachRemoteSession create path. - CLAUDE.md Remote Key Pattern notes discover/attach + detach-not-kill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> (cherry picked from commit f321e1200a9a7c1e58c69ba936b680200fd53275)
Resolutions: - session.ts: keep the extracted _buildRespawnPaneOptions() helper (COD-108) and add master's docker/owner fields to it - tmux-manager.ts: docker branch first, then remote via buildRemoteSessionCommand (now an options object threading claudeMode/allowedTools into buildRemoteLaunchCommand, preserving the 6.3 multi-user permission downgrade) - case-routes.ts: keep master's adminOnly helper; gate the new COD-105 discovery endpoint admin-only in multi-user mode (hosts are machine-level infra) - settings-ui.js: union of remoteAutoReconnect + master's header-button defaults - session-routes.ts: union of imports; session gets remote + owner Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…naming The durable-launch section predated the Ark0N#145 consolidation: owned launches use the dedicated -L codeman-remote socket with codeman-ssh-<id8> names and per-session set -t options (never -g). Discovery/attach (COD-105) genuinely target the canonical -L codeman socket; the asymmetry is now called out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Merged. Thanks for the contribution! The durability layer is exactly how this should be built: the pure backoff/eligibility split in remote-reconnect.ts, the structural detach-not-kill guarantee for non-owned sessions, and keeping every ssh line routed through buildSshConnectionArgs. Great test coverage too. Two maintainer-side additions while resolving the master conflicts (docker cases + multi-user landed in the meantime): the COD-105 discovery endpoint is now admin-gated in multi-user mode (hosts are machine-level infra, matching the hosts CRUD policy), and buildRemoteSessionCommand threads claudeMode/allowedTools through to the launch builder so the multi-user permission downgrade reaches remote agents. Also synced the doc's launch section with the shipped -L codeman-remote / codeman-ssh- naming. |
Summary
Adds a durability layer for remote SSH sessions, building on the remote-host SSH cases merged in #145. The agent runs inside
tmux -L codeman new-session -Aon the remote host so the session survives an SSH drop, and Codeman can discover and attach tocodeman-*sessions another client launched there.Scope (COD-104 to COD-109 in the downstream tracker; COD-107 already landed in #145):
codeman-*sessions on the host. Attached (owned:false) sessions detach on tab close, never kill.window-size latestso concurrent clients at different viewports do not clamp each other; adds a shared badge.All ssh command lines continue to flow through the single shell-safe
buildSshConnectionArgs()introduced with #145 (the COD-107 injection hardening), so no new command-injection surface is added.Testing
tsc --noEmit, lint, frontend-syntax, build: all clean.Design notes:
docs/remote-sessions.md.