Merge pull request #362 from timkjr/feat/omp-remote-continuation

fix(omp,remote): thread remote-omp resume/continue through respawn and reattach
This commit is contained in:
Ark0N
2026-09-12 05:14:24 +02:00
committed by GitHub
9 changed files with 329 additions and 35 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 }`
+18
View File
@@ -1775,6 +1775,19 @@ export class Session extends EventEmitter {
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
// fix intact now that resolution has moved out of the eager options build.
if (!this._muxSession) return;
// `resolveAndClaimOmpSessionId` scans THIS HOST's `~/.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. Worse than a no-op: `this.workingDir` for a
// remote session is the remote path (e.g. `/home/user/dotfiles`), so if the local machine
// happens to have its own omp history under a directory that mangles to the same name,
// this would silently claim and pin a COMPLETELY UNRELATED local session's id onto a
// remote respawn. Skip straight to the CLI's own `--continue` fallback, which the remote
// pane command already renders (see buildRemoteLaunchCommand's omp branch) — safe there
// because each remote respawn talks to exactly one remote pane's own omp history.
if (this._remote) {
this._ompConfig = { ...this._ompConfig, continueSession: true };
return;
}
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
@@ -2568,6 +2581,11 @@ export class Session extends EventEmitter {
*/
private _maybeCaptureOmpSessionId(): void {
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
// Same host-local-filesystem trap as `_pinOmpRespawnId`: the omp session file for a
// remote session lives on the remote host, not here, so scanning locally risks aliasing
// this session onto an unrelated local omp conversation that happens to mangle to the
// same directory name. Never resolvable from here — skip.
if (this._remote) return;
try {
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
+69 -8
View File
@@ -827,8 +827,11 @@ export function buildRemoteLaunchCommand(options: {
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
/** OMP only — resume/continue overrides for the remote omp relaunch (dead-pane respawn). */
ompConfig?: OmpConfig;
resumeSessionId?: string;
}): string {
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
const { mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId } = options;
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
@@ -837,11 +840,66 @@ export function buildRemoteLaunchCommand(options: {
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
// only an interactive login shell resolves (see that function's comment).
const override = remote.commands?.[mode];
const modeCommand = override
? override
: mode === 'claude'
? remoteLoginShellCommand(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)
: defaultRemoteCommandForMode(mode);
let modeCommand: string;
if (override) {
modeCommand = override;
} else if (mode === 'claude') {
// Deterministic conversation pinning for SSH-remote claude (mirrors the
// docker-claude shape in claudeDockerPaneCommand, INCLUDING the distinct
// resumeId branch it declares — this used to only mirror the same-id
// fallback shape, silently dropping an explicit resumeSessionId that
// differs from sessionId, e.g. a resume-from-history launch): the FIRST
// run creates the conversation under --session-id <sessionId>; a respawn
// / reattach re-runs the same idempotent command, --session-id exits
// non-zero ("already in use"), and the `||` fallback RESUMES that same
// conversation. Without a pinned id, every reattach relaunched a bare
// `claude` and started a NEW conversation (found live 2026-08-29: remote
// claude ctrl-d / ctrl-c relaunched a fresh session). A per-host
// `commands.claude` override stays authoritative (admin's explicit
// choice) and skips this entirely.
const permFlags = buildClaudePermissionFlags(claudeMode, allowedTools);
const cmd = `claude${permFlags}`;
// Defense in depth, mirroring claudeDockerPaneCommand's own belt-and-braces check:
// sessionId is server-minted and always safe in practice, but this command is built
// as a single shellescaped string and then executed as shell code on the remote
// host, so an unsafe value here is validated rather than trusted.
if (!RESUME_ID_SAFE.test(sessionId)) {
modeCommand = remoteLoginShellCommand(cmd);
} else {
const rid = resumeSessionId && RESUME_ID_SAFE.test(resumeSessionId) ? resumeSessionId : undefined;
modeCommand = remoteLoginShellCommand(
rid && rid !== sessionId
? `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`
: `${cmd} --session-id ${sessionId} || ${cmd} --resume ${sessionId}`
);
}
} else if (mode === 'omp') {
// Remote OMP respawn must RESUME the same conversation instead of
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
// a brand-new omp session). The pinned id, when known, is passed as an
// explicit --resume; otherwise fall back to omp's own "most recent"
// --continue so a dead-pane respawn still lands back in the conversation.
// Rendered through the CLI registry (buildSpawnCommandFromRegistry), the
// SAME mode-agnostic path local/docker spawns use — not appendResumeFlag(),
// which would hand the id to the login shell as $0 after the quoted `-c
// 'omp'`, and not a raw buildOmpCommand() call, which the registry refactor
// (#347) deleted. Gives every registry CLI with a resume form this
// behaviour for free, and the flags can't drift from the local builder.
const ompEntry = getCli('omp');
const ompCmd = ompEntry
? (buildSpawnCommandFromRegistry(ompEntry, {
mode: 'omp',
sessionId,
ompConfig: {
...ompConfig,
resumeSessionId: resumeSessionId || ompConfig?.resumeSessionId,
},
}) ?? 'omp')
: 'omp';
modeCommand = remoteLoginShellCommand(ompCmd);
} else {
modeCommand = defaultRemoteCommandForMode(mode);
}
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
@@ -1309,6 +1367,9 @@ function buildRemoteSessionCommand(options: {
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
/** OMP only — resume/continue overrides for a remote omp relaunch. */
ompConfig?: OmpConfig;
resumeSessionId?: string;
}): string {
const { remote, sessionId } = options;
if (remote.owned === false) {
@@ -1882,7 +1943,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
: localFullCmd;
// Create tmux session in three steps to handle cold-start (no server running)
@@ -2133,7 +2194,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
: localFullCmd;
try {
+22 -2
View File
@@ -22,6 +22,23 @@ import { join, sep } from 'node:path';
/** A real OMP session file is `<ISO-ish-timestamp>_<uuid>.jsonl`; only the uuid matters here. */
const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/;
/**
* Strip a trailing `/` from a workingDir unless it is the root itself.
*
* Case paths routinely end in `/` — a remote case's `remotePath` is stored
* verbatim (e.g. `/home/user/dotfiles/`) — but omp persists sessions under
* the slash-less mangle (`-dotfiles`) with a header `cwd` of
* `/home/user/dotfiles`. Without normalization, the trailing slash survives
* the mangle (`-dotfiles-`), `readdirSync` returns null for a directory that
* exists, and OMP respawn pinning silently degrades to the ambiguous
* `--continue` (found live 2026-08-29: a remote OMP ctrl-c relaunched a fresh
* conversation instead of resuming). Exported so the same normalization is
* used for the header-`cwd` comparison in {@link resolveAndClaimOmpSessionId}.
*/
export function stripTrailingSlash(workingDir: string): string {
return workingDir.length > 1 && workingDir.endsWith('/') ? workingDir.slice(0, -1) : workingDir;
}
/**
* Mirrors `omp`'s own directory mangling. Confirmed empirically against real
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
@@ -45,8 +62,9 @@ export function mangleOmpWorkingDir(workingDir: string): string {
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
// trade one silent mismatch for a different one.
const home = homedir();
const normalized = stripTrailingSlash(workingDir);
const relative =
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
normalized === home || normalized.startsWith(home + sep) ? normalized.slice(home.length) : normalized;
return relative.replace(/\//g, '-');
}
@@ -183,7 +201,9 @@ export function resolveAndClaimOmpSessionId(workingDir: string): string | null {
}
if (mtimeMs <= newestMtime) continue;
const header = readOmpSessionHeader(filePath);
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
// Compare against the slash-normalized workingDir: the session's own
// workingDir may carry a trailing slash while omp's header cwd never does.
if (!header || header.cwd !== stripTrailingSlash(workingDir) || claimedOmpSessionIds.has(header.id)) continue;
newestMtime = mtimeMs;
newestId = header.id;
}
@@ -93,6 +93,11 @@ const ALLOWED_BRANCHES: Record<string, string> = {
"tmux-manager.ts::mode === 'claude'":
"claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string',
"tmux-manager.ts::mode === 'omp'":
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
// --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'":
+45 -1
View File
@@ -25,7 +25,7 @@ import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/types.js';
import type { MuxSession, SessionRemote } from '../src/types.js';
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
@@ -126,4 +126,48 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
expect(session.claudeSessionId).toBe('real-omp-uuid');
});
it("a remote session never resolves --resume from this host's local ~/.omp, even when a same-named local session file exists", () => {
// Seed a LOCAL session file whose directory mangle happens to match this
// remote session's remotePath. If _pinOmpRespawnId() ever fell through to
// resolveAndClaimOmpSessionId() for a remote session, it would wrongly
// claim/pin this unrelated local conversation's id onto the remote respawn.
seedOmpSessionFile('wrong-local-conversation-id');
const remote: SessionRemote = {
hostId: 'remote-box',
label: 'remote-box',
host: 'remote-box',
username: 'someone',
remotePath: workingDir,
owned: true,
};
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
remote,
});
sessions.push(session);
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
const state = session.toState();
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
expect(state.ompConfig?.continueSession).toBe(true);
expect(session.claudeSessionId).toBe(session.id);
});
});
+41 -1
View File
@@ -20,7 +20,11 @@ import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
import {
findLatestOmpSessionId,
mangleOmpWorkingDir,
resolveAndClaimOmpSessionId,
} from '../src/utils/omp-session-resolver.js';
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
describe('mangleOmpWorkingDir', () => {
@@ -41,6 +45,15 @@ describe('mangleOmpWorkingDir', () => {
const sibling = `${homedir()}-other/dev/foo`;
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
});
it('normalizes a trailing slash so a remote case path resolves to the same dir', () => {
// Regression (2026-08-29): remote case paths are stored verbatim with a
// trailing slash (e.g. `/home/user/dotfiles/`), but omp persists sessions
// under the slash-less mangle (`-dotfiles`). Before the fix this produced
// `-dotfiles-`, readdirSync returned null for an existing dir, and OMP
// respawn pinning silently degraded to the ambiguous `--continue`.
expect(mangleOmpWorkingDir(join(homedir(), 'dotfiles') + '/')).toBe('-dotfiles');
});
});
describe('findLatestOmpSessionId', () => {
@@ -69,6 +82,33 @@ describe('findLatestOmpSessionId', () => {
});
});
describe('resolveAndClaimOmpSessionId: header-cwd trailing-slash normalization', () => {
// Sibling of the directory-mangle trailing-slash regression above, but for
// the OTHER half of the same fix: resolveAndClaimOmpSessionId additionally
// verifies each candidate file's own header `cwd` against workingDir (the
// mangle is lossy, so the filename-derived id alone isn't enough — see the
// function's doc comment). A remote case's workingDir carries a trailing
// slash (e.g. `/home/user/dotfiles/`) but omp's header `cwd` never does;
// without stripTrailingSlash() on BOTH sides of that comparison, a real
// on-disk session would be found by directory but rejected by the cwd
// check, silently degrading pinning to the ambiguous `--continue`.
const workingDirNoSlash = join(homedir(), 'dotfiles');
const workingDirWithSlash = `${workingDirNoSlash}/`;
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-dotfiles');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('matches a header cwd with no trailing slash against a workingDir that has one', () => {
mkdirSync(sessionDir, { recursive: true });
const header = `${JSON.stringify({ type: 'session', id: 'remote-dotfiles-uuid', cwd: workingDirNoSlash })}\n`;
writeFileSync(join(sessionDir, '2026-08-29T00-00-00-000Z_remote-dotfiles-uuid.jsonl'), header);
expect(resolveAndClaimOmpSessionId(workingDirWithSlash)).toBe('remote-dotfiles-uuid');
});
});
describe('resolveOmpConfigForCreate', () => {
// The exact pipeline "resume this OMP row from the history list" drives:
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
+25
View File
@@ -24,6 +24,31 @@ describe('COD-106 shared remote sessions', () => {
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
});
it('remote omp relaunch resumes the pinned conversation instead of starting fresh (2026-08-29)', () => {
const cmd = buildRemoteLaunchCommand({
mode: 'omp',
remote,
sessionId: 'cod106aaa',
ompConfig: { model: 'llm-proxy/crof/glm-5.3-flash' },
resumeSessionId: '01a04eb1-d883-75f0-bdfa-74cc315b09ce',
});
// The remote pane command must carry the pinned omp session id so a
// dead-pane respawn lands back in the same conversation.
expect(cmd).toContain('omp --model llm-proxy/crof/glm-5.3-flash --resume 01a04eb1-d883-75f0-bdfa-74cc315b09ce');
// still a durable, idempotent remote tmux session
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
});
it('remote omp relaunch falls back to --continue when no id is pinned', () => {
const cmd = buildRemoteLaunchCommand({
mode: 'omp',
remote,
sessionId: 'cod106aaa',
ompConfig: { continueSession: true },
});
expect(cmd).toContain('omp --continue');
});
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
const rows = parseRemoteSessionList(
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
+37
View File
@@ -172,6 +172,43 @@ describe('TmuxManager (unit)', () => {
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c');
expect(command).toContain('claude --dangerously-skip-permissions');
});
it('pins SSH-remote claude to the Codeman session id so a respawn resumes the same conversation', () => {
// Regression (2026-08-29): remote claude was launched as a bare `claude …`,
// so every reattach/respawn after a pane death (user ctrl-d or ctrl-c exit)
// started a NEW conversation. The launch now mirrors the docker-claude shape:
// `--session-id <id>` to create, with a `|| --resume <id>` fallback so the
// idempotent re-run resumes instead of erroring ("already in use").
const command = buildRemoteLaunchCommand({
mode: 'claude',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
sessionId: 'abc123def456',
});
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
expect(command).toContain('claude --dangerously-skip-permissions --resume abc123def456');
});
it('resumes an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand)', () => {
// The docker-claude builder (claudeDockerPaneCommand) has always handled a
// resumeId that differs from sessionId — e.g. a resume-from-history launch —
// by leading with `--resume <rid> || --session-id <sessionId>`. The remote
// claude branch used to only mirror the SAME-id fallback shape and silently
// dropped a distinct resumeSessionId, so a remote resume-from-history claude
// launch created a brand-new conversation instead of resuming the named one.
const command = buildRemoteLaunchCommand({
mode: 'claude',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
sessionId: 'abc123def456',
resumeSessionId: 'old-conversation-uuid',
});
expect(command).toContain('claude --dangerously-skip-permissions --resume old-conversation-uuid');
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
// The resume attempt must lead — session-id is the fallback here, reversed
// from the same-id case.
const resumeIdx = command.indexOf('--resume old-conversation-uuid');
const sessionIdIdx = command.indexOf('--session-id abc123def456');
expect(resumeIdx).toBeLessThan(sessionIdIdx);
});
});
describe('remote kill command builder', () => {