fix(remote): address review feedback on omp/claude respawn continuity

- Remote omp command now renders through buildSpawnCommandFromRegistry
  (the mode-agnostic engine local/docker spawns use) instead of the
  buildOmpCommand() the CLI-registry refactor deleted.
- Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip
  host-local ~/.omp resolution entirely for a remote session and fall
  back to --continue: that resolver only ever reads THIS host's
  filesystem, which is meaningless (and could wrongly alias an
  unrelated local conversation) for a conversation that lives on the
  remote host.
- Remote-claude launch now honors an explicit resumeSessionId distinct
  from sessionId (mirrors claudeDockerPaneCommand's shape), and
  validates sessionId the same way that sibling does before
  interpolating it into the remote shell command.
- Add the still-missing header-cwd half of the trailing-slash test,
  and document respawn/reattach continuation + auto-reconnect-vs-
  clean-exit in docs/remote-sessions.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
timkjr
2026-09-07 22:11:54 -05:00
co-authored by Claude Sonnet 5
parent 88243e9ffa
commit 797f0d387c
6 changed files with 206 additions and 32 deletions
+67 -23
View File
@@ -24,14 +24,14 @@ custom port, identity file, `-J` jump host, `-o ProxyCommand`).
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| Type | Role |
|------|------|
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| Type | Role |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir:
@@ -73,7 +73,7 @@ Rules that keep this safe — **do not bypass them by hand-building an ssh line
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
the one in `tmux-manager.ts`.
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
*before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
_before_ escaping — ssh does not expand `~` inside `-i`, and the escaped value
never reaches a shell that would.
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
@@ -112,7 +112,7 @@ Key points:
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
socket** — they join sessions the remote's own Codeman manages, while owned
durable launches live on `-L codeman-remote`.
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID _is_
the agent. The per-mode command comes from `remote.commands?.[mode]` or
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
`exec codex` / `exec gemini` / `exec agy` / `exec bash -l`).
@@ -128,9 +128,9 @@ Because durable remote sessions require tmux on the remote host,
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
creating a remote case/session and returns a structured, never-throwing result:
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
durable remote sessions"*
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
- empty stdout / non-zero exit → _"remote host `<host>` needs tmux installed for
durable remote sessions"_
- stderr present → _"could not verify tmux on remote host `<host>`: `<stderr>`"_
(a real connection failure, surfaced to the operator)
- success → `{ ok: true, tmuxPath }`
@@ -147,7 +147,7 @@ skipped; command construction is still asserted by unit tests.
## Ownership: launched vs. discovered-and-attached (COD-105)
COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
COD-104 (above) was Phase 1 — Codeman _launches_ a remote session and owns it.
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
already running on a remote host (created by the remote's own Codeman or another
instance) and **attach** to one it didn't launch. Ownership decides what happens
@@ -188,7 +188,7 @@ remote command line by ownership:
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
NOT `new-session -A`**, so it only *joins* an existing session and never creates
NOT `new-session -A`**, so it only _joins_ an existing session and never creates
one.
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
@@ -196,24 +196,68 @@ remote command line by ownership:
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
kill-session` on _this_ host's socket). Killing the local ssh sends SIGHUP to the
remote `tmux attach`, which **detaches** — the durable remote session survives.
The early return is a structural guarantee that **no code path can ever issue a
remote `kill-session` for a session we don't own** — the only `kill-session` run is
on the local socket, which never reaches the remote socket.
## Respawn / reattach continuation
A dropped connection or a dead pane must reconnect to the **same conversation**,
not launch a fresh one — the whole point of a durable remote session.
- **Claude**: the launch command is idempotent — `claude --session-id <id> ||
claude --resume <id>` (see `buildRemoteLaunchCommand`'s claude branch). The
first run creates the conversation under the deterministic session id; every
later reattach/respawn re-runs the same line, `--session-id` fails
("already in use"), and the `||` fallback resumes it.
- **OMP**: `omp` has no equivalent idempotent single-line form, so
`Session._pinOmpRespawnId()` resolves and pins an explicit `--resume <id>`
before a respawn (mirroring the local/docker builders, rendered through the
same `buildSpawnCommandFromRegistry` engine — not a hand-rolled command and
not `appendResumeFlag()`, which is docker-only and cannot work here: appending
a flag after the quoted `-c 'omp'` hands the id to the login shell as `$0`
instead of to `omp`). ⚠️ **The resolver only ever reads THIS host's local
`~/.omp/agent/sessions/`**, which is meaningless for a remote session — the
conversation and its session file live on the remote host, under the remote
user's home. For a remote session, `_pinOmpRespawnId()` therefore skips local
resolution entirely and falls back to `omp`'s own ambiguous `--continue`
(`ompConfig.continueSession`), which the remote pane command already renders.
This is a known, accepted degradation versus the local/docker paths' exact
`--resume` pin — safe in practice because each remote respawn talks to
exactly one remote pane's own omp history, so "most recent" is normally
correct, but it can drift the same way `--continue` always could if two
remote sessions ever share one remote directory.
## Auto-reconnect vs. a clean agent exit
`remoteAutoReconnect` (default ON) watches for a dropped SSH connection and
reconnects with bounded backoff. It must **never** revive a session whose agent
exited cleanly (Ctrl-C, Ctrl-D, `exit`) — that tears down the durable remote
tmux session itself, and a transport-level `isPaneDead()` cannot tell that apart
from a plain network drop. `remoteTmuxSessionAlive()` (#355) resolves this by
probing the remote host directly: `tmux -L codeman-remote has-session -t
codeman-ssh-<id8>` over the same `buildSshConnectionArgs` as launch, classified
by **exit status alone** (`classifyRemoteAliveExit`: `0` = alive, ssh's `255` or
a timeout = unknown, anything else = gone) — `has-session` prints nothing on
success, so reading stdout would misclassify every live session as gone. An
unreachable host answers "unknown", which also means do not revive. The answer
is cached per session and cleared whenever the pane is next seen alive, so a
stale `true` from one transport drop can never revive the NEXT clean exit.
## API
Routes are registered in `src/web/routes/case-routes.ts`:
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/remote-hosts` | List saved hosts |
| `POST` | `/api/remote-hosts` | Create a host |
| `PUT` | `/api/remote-hosts/:id` | Update a host |
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
| Method | Path | Purpose |
| -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `GET` | `/api/remote-hosts` | List saved hosts |
| `POST` | `/api/remote-hosts` | Create a host |
| `PUT` | `/api/remote-hosts/:id` | Update a host |
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
Attaching to a discovered session is a **session-create** path, not a host route:
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`