From 9fc2773176f1086cec8c8d6407e3241c236bf571 Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:08:48 +0000 Subject: [PATCH] fix(tmux): bind nested prefix before unbind so an empty table cannot abort the chain A user tmux config that leaves the prefix table empty makes unbind-key -a -T prefix fail and drop later commands in the \; chain, including send-prefix and remain-on-exit failed. Bind C-q send-prefix, clear the table, then bind it again. Also accept macOS ttys in the TUI switch-guard test without reading /proc, and describe the client_pid hook escape as defensive on tmux 3.4. Co-authored-by: DDT <1786035110@qq.com> --- docs/architecture-invariants.md | 2 +- docs/remote-sessions.md | 8 +++++-- src/tmux-manager.ts | 11 +++++---- src/tmux-prefix-table.ts | 18 ++++++++++---- src/tui/tui-client.ts | 8 ++++--- test/docker-exec-options.test.ts | 10 +++++--- test/remote-ssh-options.test.ts | 7 ++++-- test/tmux-prefix-table.test.ts | 39 +++++++++++++++++++++++++++++-- test/tui/tui-client.test.ts | 5 ++-- test/tui/tui-switch-guard.test.ts | 26 +++++++++++++++++---- 10 files changed, 106 insertions(+), 28 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1e5c234c..63cdb9f8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -18,7 +18,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### The prefix table on Codeman's socket holds only send-prefix -**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to ` send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. The same reduction is applied when Codeman creates the nested tmux behind a Docker case (`-L codeman-docker`) or an owned remote session (`-L codeman-remote`). Those servers set `prefix C-q` and used to keep tmux's stock prefix table, so inside the pane Ctrl+Q then `)` `(` `s` `w` or `L` ran `switch-client` / `choose-tree` on the inner server and moved its client onto a sibling session. `nestedPrefixTableResetCommands()` (`unbind-key -a -T prefix`, then `bind-key -T prefix C-q send-prefix`) is appended to both create chains, before `remain-on-exit failed` on the remote chain so a remote tmux that rejects `failed` cannot drop it. Those sockets are dedicated to Codeman, so the server-global table is the right scope. A non-owned remote attach joins another Codeman's `-L codeman` and does not run it. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C- detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide. F1 and the held-Ctrl `C-d` still detach every client for the length of an attach (a way out, not a session move). **`M-1`..`M-9` do not move other clients.** `bindSwitchKey` binds them to `if-shell -F` (`tuiSwitchGuard`) so `switch-client` runs only when `#{client_tty}` is the TUI's terminal and `#{client_pid}` equals `@codeman-tui-pid`. A one-shot `client-attached[90]` hook (`tuiSwitchAttachHook`) sets that option as the TUI's client attaches, then removes itself. A browser tab does not match, including Esc followed by a digit within `escape-time` (500 ms on tmux 3.4), which arrives as the same `M-`. If the TUI is killed before `restore()`, the binding can remain, but the stored pid is the dead client and the hook will not re-arm a pty that reuses the number, so the key does nothing. `restore()` removes the keys only while they still name this tty, and the next attach that claims the strip releases any of these bindings first. A second TUI attach replaces the keys (one binding per key). Escape-time on Codeman's socket stays at tmux's default: lowering it would change Esc delivery for every pane, and the guard already refuses other clients. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client, including the nested `C-q` table), `test/tui/tui-switch-guard.test.ts` (the guarded Alt keys against a second client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`. +**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to ` send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. The same reduction is applied when Codeman creates the nested tmux behind a Docker case (`-L codeman-docker`) or an owned remote session (`-L codeman-remote`). Those servers set `prefix C-q` and used to keep tmux's stock prefix table, so inside the pane Ctrl+Q then `)` `(` `s` `w` or `L` ran `switch-client` / `choose-tree` on the inner server and moved its client onto a sibling session. `nestedPrefixTableResetCommands()` (bind `C-q send-prefix`, then `unbind-key -a -T prefix`, then bind it again, so a user config that left the prefix table empty cannot make `unbind-key -a` abort the `\;` chain) is appended to both create chains, before `remain-on-exit failed` on the remote chain so a remote tmux that rejects `failed` cannot drop it. Those sockets are dedicated to Codeman, so the server-global table is the right scope. A non-owned remote attach joins another Codeman's `-L codeman` and does not run it. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C- detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide. F1 and the held-Ctrl `C-d` still detach every client for the length of an attach (a way out, not a session move). **`M-1`..`M-9` do not move other clients.** `bindSwitchKey` binds them to `if-shell -F` (`tuiSwitchGuard`) so `switch-client` runs only when `#{client_tty}` is the TUI's terminal and `#{client_pid}` equals `@codeman-tui-pid`. A one-shot `client-attached[90]` hook (`tuiSwitchAttachHook`) sets that option as the TUI's client attaches, then removes itself. A browser tab does not match, including Esc followed by a digit within `escape-time` (500 ms on tmux 3.4), which arrives as the same `M-`. If the TUI is killed before `restore()`, the binding can remain, but the stored pid is the dead client and the hook will not re-arm a pty that reuses the number, so the key does nothing. `restore()` removes the keys only while they still name this tty, and the next attach that claims the strip releases any of these bindings first. A second TUI attach replaces the keys (one binding per key). Escape-time on Codeman's socket stays at tmux's default: lowering it would change Esc delivery for every pane, and the guard already refuses other clients. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client, including the nested `C-q` table), `test/tui/tui-switch-guard.test.ts` (the guarded Alt keys against a second client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`. ### Reverse-proxy base path diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 10fa793a..e22c83d5 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -95,7 +95,8 @@ ssh -o BatchMode=yes -t user@host \ set -t codeman-ssh- status off \; set -t codeman-ssh- mouse off \; \ set -t codeman-ssh- prefix C-q \; set -s escape-time 0 \; \ set -t codeman-ssh- window-size latest \; \ - unbind-key -a -T prefix \; bind-key -T prefix C-q send-prefix' + bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \ + bind-key -T prefix C-q send-prefix' ``` Key points: @@ -111,7 +112,10 @@ Key points: uses. Options are set per-session (`set -t`), never `-g`, so a shared remote tmux server's other sessions are untouched (#145 hardening). The prefix *table* is server-global, so the create chain reduces it to `C-q send-prefix` - (`unbind-key -a -T prefix` then `bind-key`). Otherwise Ctrl+Q then `)` moves + (bind `send-prefix`, then `unbind-key -a -T prefix`, then bind it again). The + leading bind keeps an empty prefix table from aborting the chain: + `unbind-key -a` fails with "table prefix doesn't exist" and tmux drops every + later command, including `remain-on-exit failed`. Without that reduction, Ctrl+Q then `)` moves the inner client onto a sibling session. A non-owned attach does not do this: it joins the remote Codeman's own socket. Note the asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman` diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 48defcc6..dde21ee2 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1230,7 +1230,8 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): { * 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c "cd && exec " \ * \; set -t codeman-ssh- status off \; set -t codeman-ssh- mouse off \ * \; set -t codeman-ssh- prefix C-q \; set -s escape-time 0 \; \ - * unbind-key -a -T prefix \; bind-key -T prefix C-q send-prefix' + * bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \ + * bind-key -T prefix C-q send-prefix' * * COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`, * arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the @@ -1356,9 +1357,11 @@ export function buildRemoteLaunchCommand(options: { // The prefix TABLE is server-global (unlike `prefix C-q`, which is per // session). This socket is dedicated to Codeman's owned sessions, so the // same reduction as the local socket applies: Ctrl+Q then )/(/s/w/L must - // not switch-client / choose-tree onto a sibling. BEFORE remain-on-exit, - // which stays last because an old remote tmux rejects `failed` and aborts - // the rest of the chain. + // not switch-client / choose-tree onto a sibling. Bind, then unbind-all, + // then bind again: a user config that left the prefix table empty makes + // `unbind-key -a -T prefix` fail and abort the rest of this chain. + // BEFORE remain-on-exit, which stays last because an old remote tmux + // rejects `failed` and aborts the rest of the chain. ...nestedPrefixTableResetCommands(), // #210: keep a CRASHED pane so the failure is still on screen. Without this, // tmux destroys the pane -> window -> session (and, being the only session, diff --git a/src/tmux-prefix-table.ts b/src/tmux-prefix-table.ts index 60874300..3af1d7da 100644 --- a/src/tmux-prefix-table.ts +++ b/src/tmux-prefix-table.ts @@ -63,11 +63,21 @@ export const NESTED_TMUX_PREFIX = 'C-q'; * Joined with `\;` onto the `new-session` that creates the server * (`buildRemoteLaunchCommand`, `buildDockerLaunchCommand`). No `tmux` binary * and no `-g`: the table is server-global, which is what we want on these - * dedicated sockets and nowhere else. Put them BEFORE `remain-on-exit failed` - * so a remote tmux that rejects `failed` does not drop the reduction. + * dedicated sockets and nowhere else. + * + * Those servers load the user's tmux config (no `-f /dev/null`). A config that + * leaves the prefix table empty makes `unbind-key -a -T prefix` fail with + * "table prefix doesn't exist", and tmux then drops every later command in the + * `\;` chain, including the bind and `remain-on-exit failed` (#210). The same + * empty table is what #669 handled on the local socket. Binding `send-prefix` + * first creates the table so the unbind can run; the unbind clears stock + * bindings (and that first bind); the second bind puts `send-prefix` back. + * Put them BEFORE `remain-on-exit failed` so a remote tmux that rejects + * `failed` does not drop the reduction. */ -export function nestedPrefixTableResetCommands(): readonly [string, string] { - return ['unbind-key -a -T prefix', `bind-key -T prefix ${NESTED_TMUX_PREFIX} send-prefix`]; +export function nestedPrefixTableResetCommands(): readonly [string, string, string] { + const sendPrefix = `bind-key -T prefix ${NESTED_TMUX_PREFIX} send-prefix`; + return [sendPrefix, 'unbind-key -a -T prefix', sendPrefix]; } /** One `list-keys -T prefix` line: key and command. */ diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 735ec945..e4c72cb3 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -491,9 +491,11 @@ export function tuiSwitchGuard(tty: string): string { } /** - * `set-hook` command for `TUI_SWITCH_ATTACH_HOOK`. `\#{client_pid}` is deferred - * so tmux expands it when the hook fires, not when the hook is stored (a bare - * `#{client_pid}` is expanded at `set-hook` time, in no client, and stored empty). + * `set-hook` command for `TUI_SWITCH_ATTACH_HOOK`. `\#{client_pid}` is + * defensive. On tmux 3.4 both this form and a bare `#{client_pid}` are stored + * verbatim and both record the attaching client's pid. The escape stays so a + * tmux that expands formats while the hook is stored (no client exists yet) + * cannot save an empty pid. */ export function tuiSwitchAttachHook(tty: string): string { return ( diff --git a/test/docker-exec-options.test.ts b/test/docker-exec-options.test.ts index 247c19c4..f48ae656 100644 --- a/test/docker-exec-options.test.ts +++ b/test/docker-exec-options.test.ts @@ -108,10 +108,14 @@ describe('buildDockerLaunchCommand', () => { expect(cmd).toContain("sh -lc '"); // The in-container tmux keeps prefix C-q. Without this reduction its stock // prefix table runs switch-client / choose-tree for the inner client. + // Bind, unbind-all, bind again: an empty prefix table makes unbind-key -a + // fail and abort the rest of the \; chain. + const firstBind = cmd.indexOf('bind-key -T prefix C-q send-prefix'); const unbind = cmd.indexOf('unbind-key -a -T prefix'); - const bind = cmd.indexOf('bind-key -T prefix C-q send-prefix'); - expect(unbind).toBeGreaterThan(cmd.indexOf('prefix C-q')); - expect(bind).toBeGreaterThan(unbind); + const secondBind = cmd.indexOf('bind-key -T prefix C-q send-prefix', unbind); + expect(firstBind).toBeGreaterThan(cmd.indexOf('prefix C-q')); + expect(unbind).toBeGreaterThan(firstBind); + expect(secondBind).toBeGreaterThan(unbind); }); it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => { diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index acddd56a..461774a9 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -166,8 +166,11 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => { `set -t ${remoteName} window-size latest`, // #671 — the nested server's prefix table is stock unless this chain // reduces it. Ctrl+Q then ) would switch-client onto a sibling session. - // Before remain-on-exit: a remote tmux that rejects `failed` aborts the - // rest of the chain, and that option has to stay last. + // Bind first: a user config that emptied the prefix table makes + // `unbind-key -a` fail and tmux drops the rest of the `\;` chain, + // including the bind and remain-on-exit. That option stays last because + // a remote tmux that rejects `failed` aborts the rest of the chain. + 'bind-key -T prefix C-q send-prefix', 'unbind-key -a -T prefix', 'bind-key -T prefix C-q send-prefix', // #210 — keep a CRASHED pane for diagnosis. `failed` (not `on`, which would diff --git a/test/tmux-prefix-table.test.ts b/test/tmux-prefix-table.test.ts index f1d7a82a..6d497f12 100644 --- a/test/tmux-prefix-table.test.ts +++ b/test/tmux-prefix-table.test.ts @@ -13,7 +13,7 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execSync } from 'node:child_process'; -import { rmSync } from 'node:fs'; +import { rmSync, writeFileSync } from 'node:fs'; import * as pty from 'node-pty'; import { nestedPrefixTableResetCommands, @@ -285,7 +285,7 @@ describe.skipIf(!hasTmux)('restrictTmuxPrefixTable against a real tmux (private describe.skipIf(!hasTmux)('nested Docker/remote tmux prefix table (private socket)', () => { // What buildRemoteLaunchCommand / buildDockerLaunchCommand create: prefix C-q - // on a dedicated server, then the two commands nestedPrefixTableResetCommands + // on a dedicated server, then the commands nestedPrefixTableResetCommands // emits. The inner client is the docker-exec / ssh process; Ctrl+Q is its prefix. const socket = `codeman-nested-prefix-${process.pid}`; const tmux = `tmux -L ${socket} -f /dev/null`; @@ -362,4 +362,39 @@ describe.skipIf(!hasTmux)('nested Docker/remote tmux prefix table (private socke client!.write('\x11\x11'); expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^Q'))).toBe(true); }); + + it('keeps the reset and the next command when the config left the prefix table empty', () => { + // Nested servers load the user's tmux config (no -f /dev/null). A config + // that empties the prefix table makes `unbind-key -a -T prefix` fail with + // "table prefix doesn't exist", and tmux drops every later command in the + // \; chain (the bind, and remain-on-exit failed). Binding send-prefix + // first is what lets both still run. One invocation, as the create chain is. + const socket = `codeman-nested-empty-${process.pid}`; + const config = `/tmp/${socket}.conf`; + writeFileSync(config, 'unbind-key -a -T prefix\n'); + const base = `tmux -L ${socket}`; + const session = 'alpha'; + const chain = [ + `new-session -d -s ${session} -x 80 -y 24 'sleep 30'`, + ...nestedPrefixTableResetCommands(), + `set -t ${session} remain-on-exit failed`, + ].join(' \\; '); + try { + run(`${base} -f ${config} ${chain}`); + const prefix = run(`${base} list-keys -T prefix`) + .split('\n') + .filter((line) => line.trim()); + expect(prefix).toEqual([expect.stringMatching(/-T prefix C-q\s+send-prefix$/)]); + expect(run(`${base} show-options -wv -t ${session} remain-on-exit`).trim()).toBe('failed'); + } finally { + try { + const socketPath = run(`${base} display-message -p '#{socket_path}'`).trim(); + run(`${base} kill-server`); + if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true }); + } catch { + /* already gone */ + } + rmSync(config, { force: true }); + } + }); }); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 9e0da27e..a9858950 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -682,8 +682,9 @@ describe('TUI switch-key guard', () => { expect(isTuiClientTty('/dev/pts/4; rm')).toBe(false); expect(tuiSwitchGuard('/dev/pts/4')).toContain('#{client_tty},/dev/pts/4'); expect(tuiSwitchGuard('/dev/pts/4')).toContain('#{@codeman-tui-pid}'); - // A bare #{client_pid} is expanded when the hook is stored, with no client - // attached, and the pid is then empty forever. The backslash defers it. + // Defensive. On tmux 3.4 a bare #{client_pid} is also stored verbatim and + // records the pid. The backslash stays so a tmux that expands formats while + // storing the hook cannot save an empty pid. const hook = tuiSwitchAttachHook('/dev/pts/4'); expect(hook).toContain('/dev/pts/4'); expect(hook).toContain('\\#{client_pid}'); diff --git a/test/tui/tui-switch-guard.test.ts b/test/tui/tui-switch-guard.test.ts index 8cf62461..09a9c6d4 100644 --- a/test/tui/tui-switch-guard.test.ts +++ b/test/tui/tui-switch-guard.test.ts @@ -11,7 +11,7 @@ */ import { afterAll, describe, expect, it } from 'vitest'; import { execFileSync, execSync, spawn, type ChildProcess } from 'node:child_process'; -import { readlinkSync, rmSync } from 'node:fs'; +import { rmSync } from 'node:fs'; import { createInterface } from 'node:readline'; import * as pty from 'node-pty'; import { TuiClient } from '../../src/tui/tui-client.js'; @@ -70,6 +70,9 @@ except ChildProcessError: os.close(master) `; +/** Linux `/dev/pts/N` and macOS `/dev/ttysNNN`, the same ttys `isTuiClientTty` accepts. */ +const CLIENT_TTY = /^\/dev\/(?:pts\/[0-9]+|ttys[0-9]+)$/; + describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () => { const socket = `codeman-tui-switch-${process.pid}`; const alpha = 'codeman-aaaa1111'; @@ -146,13 +149,14 @@ describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () => helper = spawn('python3', ['-c', PTY_HELPER, socket, alpha], { stdio: ['pipe', 'pipe', 'inherit'] }); createInterface({ input: helper.stdout! }).on('line', pushLine); const tty = (await nextLine()).trim(); - expect(tty).toMatch(/^\/dev\/pts\/[0-9]+$/); + expect(tty).toMatch(CLIENT_TTY); expect(await client.armTuiSwitchGuard(tty)).toBe(true); const hooks = run(['show-hooks', '-g']); expect(hooks).toContain('client-attached[90]'); expect(hooks).toContain(tty); - // Expanded at set-hook time this is empty and the guard never matches. + // The stored hook still names the format. Expanding it while the hook is + // stored would save an empty pid (no client yet) and the guard would never match. expect(hooks).toContain('#{client_pid}'); helper.stdin!.write('GO\n'); @@ -185,9 +189,21 @@ describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () => rows: 24, env, }); - const otherTty = readlinkSync(`/proc/${other.pid}/fd/0`); + // list-clients is the portable tty: /proc//fd/0 does not exist on macOS. + let otherTty = ''; + expect( + await waitFor(() => { + for (const [candidate, info] of sessions()) { + if (candidate !== tty && info.session === alpha) { + otherTty = candidate; + return true; + } + } + return false; + }) + ).toBe(true); + expect(otherTty).toMatch(CLIENT_TTY); expect(otherTty).not.toBe(tty); - expect(await waitFor(() => sessions().get(otherTty)?.session === alpha)).toBe(true); other.write('\x1b2'); await new Promise((resolve) => setTimeout(resolve, 400));