fix: apply the review items for #688 at merge

disarmTuiSwitchGuard matches the closed `#{client_tty},<tty>}` form, so a TUI on /dev/pts/4 no longer removes the hook armed for a second TUI on /dev/pts/41 (unit test plus a real-tmux case)
beginAttachHandoff arms the one-shot attach hook as its last tmux call before spawnSync, after the switch keys and the per-session dressing loop, with an order assertion in tui-app.test.ts and a sentence in the invariants paragraph
docs/remote-sessions.md and the buildRemoteLaunchCommand JSDoc show `remain-on-exit failed` at the end of the remote create chain

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-24 14:10:07 +02:00
1 parent 9fc2773176
commit 311d01c52b
8 files changed
+69 -10

No files matched your search

+1 -1
View File
@@ -1231,7 +1231,7 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
* bind-key -T prefix C-q send-prefix'
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
+12 -3
View File
@@ -603,9 +603,9 @@ export async function beginAttachHandoff(
const tty = clientTty === undefined ? readControllingTty() : clientTty || null;
const switchTabs = tabs.slice(0, 9).filter((tab) => tab.index >= 1 && tab.index <= 9 && tab.muxName);
const guarded = Boolean(tty && isTuiClientTty(tty));
if (!guarded) {
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`, '');
} else if (switchTabs.length > 0 && (await client.armTuiSwitchGuard(tty as string))) {
// The keys are bound here but stay inert until the attach hook records this
// client's pid, and that hook is armed at the very end (see below).
if (guarded && switchTabs.length > 0) {
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`);
for (const tab of switchTabs) {
const key = `M-${tab.index}`;
@@ -660,6 +660,15 @@ export async function beginAttachHandoff(
if (cols && rows && rows > 1) await client.presizeWindow(target, cols, rows - 1);
if (target === muxName) await client.followAttachingClient(target);
}
// ⚠️ Armed LAST, as the final tmux call before the caller's `spawnSync`. The
// hook is one-shot for this tty, so a TUI killed between arming and attaching
// leaves it armed, and Linux hands the lowest free pty to the next opener: a
// browser tab's attach client could land on this tty and have its pid
// recorded, which would make these keys move that tab. Arming after the
// dressing loop above shrinks that window to the attach's own startup. A
// failed arm leaves the keys inert (the closed direction), and `restore()`
// still unbinds them.
if (switchKeys.length > 0) await client.armTuiSwitchGuard(tty as string);
return {
chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined),
async restore(): Promise<void> {
+4 -1
View File
@@ -1111,7 +1111,10 @@ export class TuiClient {
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-hooks', '-g']);
const hook = stdout.split('\n').find((line) => line.startsWith(`${TUI_SWITCH_ATTACH_HOOK} `));
if (hook?.includes(clientTty)) {
// The closed form, not the bare tty: `/dev/pts/4` is a substring of a
// hook armed for `/dev/pts/41`, and removing that one would leave the
// second TUI's Alt keys dead for its whole attach.
if (hook?.includes(`#{client_tty},${clientTty}}`)) {
await this.exec('tmux', ['-L', this.socket, 'set-hook', '-gu', TUI_SWITCH_ATTACH_HOOK]);
}
} catch {