mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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>
176 lines
7.9 KiB
TypeScript
176 lines
7.9 KiB
TypeScript
/**
|
|
* @fileoverview Keep tmux's prefix table on Codeman's socket down to `send-prefix`.
|
|
*
|
|
* Codeman drives tmux through commands, never through key bindings, but every
|
|
* keystroke typed into a browser terminal reaches the `tmux attach-session`
|
|
* client behind it. With tmux's stock prefix table that client obeys the whole
|
|
* menu: Ctrl+B then `(`, `)`, `L`, `s` or `w` runs `switch-client` /
|
|
* `choose-tree` and re-attaches the tab's client to a DIFFERENT session, so the
|
|
* tab shows, and types into, another session's agent while Codeman still labels
|
|
* it as the original. Ctrl+B `d` detaches the client, `$` renames the session
|
|
* under Codeman's feet, `:` opens tmux's command prompt. Ctrl+B is not a rare
|
|
* key in a terminal (readline backward-char, Claude Code's background chord),
|
|
* and whatever is typed next lands in that menu.
|
|
*
|
|
* The prefix itself stays: Claude Code running inside tmux asks for
|
|
* "ctrl+b ctrl+b" to background a command, which only works through
|
|
* `send-prefix`. So the table keeps exactly that one binding, on whatever key
|
|
* the socket's prefix is (the socket reads the user's `~/.tmux.conf`).
|
|
*
|
|
* `codeman tui` shares the socket (src/tui/tui-app.ts, beginAttachHandoff). For
|
|
* the length of an attach it claims a held-Ctrl detach alias (`C-d`) in the
|
|
* prefix table, and its one-key way out (F1) and Alt+1..9 in the `root` table.
|
|
* `root` is never touched here, and a `C-<letter> detach-client` binding is left
|
|
* alone (and put back when a reset clears the rest of the table around it), so
|
|
* creating a session while a TUI attach is open does not take the attach's way
|
|
* out away. The reset only runs while the table holds anything else, i.e. once
|
|
* per tmux server in practice.
|
|
*
|
|
* That tolerance is server-wide like every key table: while such an alias is
|
|
* bound, a browser tab's client can detach itself with it too. F1 in `root`
|
|
* is the same kind of way out. Alt+1..9 are not: `codeman tui` binds those
|
|
* through `if-shell -F` so `switch-client` runs only for the TUI's own client
|
|
* (see `bindSwitchKey` in src/tui/tui-client.ts). This file still does not
|
|
* touch `root`.
|
|
*
|
|
* Docker cases and owned remote sessions run a second tmux (`-L codeman-docker`,
|
|
* `-L codeman-remote`) with `prefix C-q`. That server is not this socket, so
|
|
* the reset above never sees it, and its stock prefix table still switches
|
|
* the inner client. `nestedPrefixTableResetCommands` is the same reduction,
|
|
* appended to those create chains. A non-owned remote attach joins another
|
|
* Codeman's `-L codeman` and must not run it.
|
|
*
|
|
* @module tmux-prefix-table
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
|
import { shellescape } from './remote-hosts.js';
|
|
|
|
/** tmux's stock prefix, used when `show-options` cannot be read. */
|
|
const DEFAULT_PREFIX = 'C-b';
|
|
|
|
/**
|
|
* Prefix on the dedicated tmux inside a Docker case and an owned remote
|
|
* session. Codeman's own socket keeps the user's prefix; these nested servers
|
|
* always use C-q so it does not collide with the outer prefix.
|
|
*/
|
|
export const NESTED_TMUX_PREFIX = 'C-q';
|
|
|
|
/**
|
|
* Commands that reduce a nested tmux's prefix table to `C-q send-prefix`.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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, 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. */
|
|
const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/;
|
|
|
|
/** The held-Ctrl detach alias `codeman tui` claims during an attach (heldCtrlAlias). */
|
|
const TUI_DETACH_ALIAS = /^C-[a-z]$/;
|
|
|
|
/**
|
|
* A key as `list-keys` prints it, without tmux's backslash escaping: `show-options`
|
|
* prints the prefix `C-\` as is, while the listing shows `C-\\` (and `\#`, `\$`, `\"`).
|
|
*/
|
|
function unescapeListedKey(key: string): string {
|
|
return key.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
/**
|
|
* Whether the prefix table (as printed by `tmux list-keys -T prefix`) still holds
|
|
* a binding other than `<prefix> send-prefix` or a TUI detach alias, or lacks
|
|
* `<prefix> send-prefix`. Unparseable output counts as "needs a reset":
|
|
* resetting is idempotent, skipping one leaves `switch-client` reachable from
|
|
* the browser. A prefix of `None` expects no `send-prefix` binding at all.
|
|
*/
|
|
export function prefixTableNeedsReset(listKeysOutput: string, prefix: string = DEFAULT_PREFIX): boolean {
|
|
let hasSendPrefix = false;
|
|
for (const raw of listKeysOutput.split('\n')) {
|
|
const line = raw.trim();
|
|
if (!line) continue;
|
|
const match = PREFIX_LINE.exec(line);
|
|
if (!match) return true;
|
|
const key = unescapeListedKey(match[1]);
|
|
const command = match[2].trim();
|
|
if (command === 'send-prefix' && key === prefix) {
|
|
hasSendPrefix = true;
|
|
continue;
|
|
}
|
|
if (command === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) continue;
|
|
return true;
|
|
}
|
|
return prefix === 'None' ? false : !hasSendPrefix;
|
|
}
|
|
|
|
/** The `C-<letter> detach-client` bindings in a `list-keys -T prefix` listing (see prefixTableNeedsReset). */
|
|
function tuiDetachAliases(listKeysOutput: string, prefix: string): string[] {
|
|
const keys: string[] = [];
|
|
for (const raw of listKeysOutput.split('\n')) {
|
|
const match = PREFIX_LINE.exec(raw.trim());
|
|
if (!match) continue;
|
|
const key = unescapeListedKey(match[1]);
|
|
if (match[2].trim() === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) keys.push(key);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/** Runs one tmux command line and returns its stdout. */
|
|
export type TmuxRunner = (command: string) => string;
|
|
|
|
const defaultRunner: TmuxRunner = (command) =>
|
|
execSync(command, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
|
|
/**
|
|
* Reduce the prefix table of the tmux server behind `tmux` (e.g. `tmux -L 'codeman'`)
|
|
* to `<prefix> send-prefix`, when it holds anything more. Never throws: a failure
|
|
* only leaves tmux's stock bindings in place. Returns whether a reset ran.
|
|
*/
|
|
export function restrictTmuxPrefixTable(tmux: string, run: TmuxRunner = defaultRunner): boolean {
|
|
let prefix = DEFAULT_PREFIX;
|
|
try {
|
|
prefix = run(`${tmux} show-options -gv prefix`).trim() || DEFAULT_PREFIX;
|
|
} catch {
|
|
/* keep tmux's stock prefix */
|
|
}
|
|
let listing: string | null;
|
|
try {
|
|
listing = run(`${tmux} list-keys -T prefix`);
|
|
} catch {
|
|
listing = null; // an empty table makes list-keys fail on some tmux builds
|
|
}
|
|
if (listing !== null && !prefixTableNeedsReset(listing, prefix)) return false;
|
|
try {
|
|
run(`${tmux} unbind-key -a -T prefix`);
|
|
} catch {
|
|
/* an empty table makes unbind-key -a fail too ("table prefix doesn't exist"); nothing to clear */
|
|
}
|
|
try {
|
|
if (prefix !== 'None') run(`${tmux} bind-key -T prefix ${shellescape(prefix)} send-prefix`);
|
|
// `unbind-key -a` also took a running TUI attach's held-Ctrl way out; give it back.
|
|
for (const key of tuiDetachAliases(listing ?? '', prefix)) {
|
|
run(`${tmux} bind-key -T prefix ${shellescape(key)} detach-client`);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|