From 99aa85dde9f740a56e6cc37717bb0febaf6b402c Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 21:15:25 +0200 Subject: [PATCH 1/4] fix(hooks): let the dev-server block see a tmux launch past tmux options The block allowed a dev server only when the segment began with `tmux new`, `new-session`, `new-window` or `split-window`. It therefore blocked dev servers that do start in a tmux pane, the way its own message asks: `tmux -L work new-session ...` and `tmux -f conf new-session ...` (tmux options before the command), `/opt/homebrew/bin/tmux new-session ...` and `env TERM=... tmux new-session ...`, the aliases `neww` and `splitw`, and `tmux send-keys -t dev 'npm run dev' Enter` or `respawn-pane` for a session that exists. Read the tmux command from the parsed segment instead, after the leading command word and tmux's own options, and allow the commands that run their shell command in a pane. `tmux run-shell` and `tmux -c` run it without one and stay blocked. --- scripts/hooks/pre-bash-dev-server-block.js | 52 ++++++++++++++++--- tests/hooks/pre-bash-dev-server-block.test.js | 28 ++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 06eb64020..5dcaddb56 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -104,7 +104,7 @@ function normalizeCommandWord(token) { return base.replace(/\.(cmd|exe|bat)$/i, ''); } -function getLeadingCommandWord(segment) { +function getLeadingCommand(segment) { let index = 0; let activeWrapper = null; let skipNextValue = false; @@ -145,7 +145,7 @@ function getLeadingCommandWord(segment) { continue; } - return normalizedToken; + return { word: normalizedToken, end: index }; } return null; @@ -160,7 +160,18 @@ process.stdin.on('data', chunk => { } }); -const TMUX_LAUNCHER = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; +// tmux commands, and their aliases, that run their shell command in a tmux pane, +// where its output stays readable. run-shell and `tmux -c` run it without one. +const TMUX_PANE_COMMANDS = new Set([ + 'new-session', 'new', + 'new-window', 'neww', + 'split-window', 'splitw', + 'respawn-pane', 'respawnp', + 'respawn-window', 'respawnw', + 'send-keys', 'send' +]); +// Options that come before the tmux command and take a value. +const TMUX_OPTIONS_WITH_VALUE = new Set(['-f', '-L', '-S', '-T']); // Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so // `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` / // `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches @@ -199,10 +210,39 @@ function collectCheckSegments(cmd) { return segments; } +/** + * Return true when the tmux invocation that starts at `index` runs its command + * in a pane. The tmux command follows tmux's own options (`-L work`, `-f conf`), + * and the executable may come through a path or an env prefix, so this reads + * the parsed tokens instead of the start of the segment. + */ +function isTmuxPaneLaunch(segment, index) { + while (index < segment.length) { + const parsed = readToken(segment, index); + if (!parsed) return false; + index = parsed.end; + + const token = parsed.token; + if (!token) continue; + if (token === '-c') return false; + if (TMUX_OPTIONS_WITH_VALUE.has(token)) { + const value = readToken(segment, index); + if (!value) return false; + index = value.end; + continue; + } + if (isOptionToken(token)) continue; + return TMUX_PANE_COMMANDS.has(token); + } + + return false; +} + function isBlockedDevSegment(segment) { - const commandWord = getLeadingCommandWord(segment); - if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false; - return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment); + const command = getLeadingCommand(segment); + if (!command || !DEV_COMMAND_WORDS.has(command.word)) return false; + if (!DEV_PATTERN.test(segment)) return false; + return !(command.word === 'tmux' && isTmuxPaneLaunch(segment, command.end)); } process.stdin.on('end', () => { diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 89524b1e0..695a16846 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -68,6 +68,13 @@ function runTests() { const result = runScript('npm run dev:ssr'); assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); }) ? passed++ : failed++); + + (test('still blocks tmux run-shell and tmux -c — no pane keeps the log (exit 2)', () => { + for (const command of ['tmux run-shell "npm run dev"', 'tmux -L work run-shell "npm run dev"', 'tmux -c "npm run dev"']) { + const result = runScript(command); + assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); } else { console.log(' (skipping blocking tests on Windows)\n'); } @@ -79,6 +86,27 @@ function runTests() { assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); }) ? passed++ : failed++); + (test('allows a tmux launch after tmux options, a path, an env prefix or a command alias (exit code 0)', () => { + for (const command of [ + 'tmux -L work new-session -d -s dev "npm run dev"', + 'tmux -f ~/.tmux.dev.conf new-session -d "npm run dev"', + '/opt/homebrew/bin/tmux new-session -d -s dev "npm run dev"', + 'env TERM=xterm-256color tmux new-session -d "npm run dev"', + 'tmux neww "npm run dev"', + 'tmux splitw -h "pnpm dev"' + ]) { + const result = runScript(command); + assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); + + (test('allows npm run dev sent to or respawned in a tmux pane (exit code 0)', () => { + for (const command of ['tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"']) { + const result = runScript(command); + assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); + (test('allows npm install (exit code 0)', () => { const result = runScript('npm install'); assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); From 87945aac1fee7cfadad4f18b3d77b36ecbb607ea Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 21:59:23 +0200 Subject: [PATCH 2/4] fix(hooks): read tmux option clusters and command sequences in the dev-server block Two forms got the pane exemption although they run the dev server without a pane. `tmux -vL new-session run-shell ...`: in a cluster, the last option that takes a value (f, L, S, T) takes the next token, here the socket name `new-session`. A `c` anywhere in a cluster (`-c'npm run dev'`) runs a shell command without a pane. `tmux new-session -d \; run-shell "npm run dev"`: only the first command of a sequence was checked. The sequence is now split at `;`, and it is exempt only when every command in it that runs the dev server is a pane command. This also closes the same hole for the plain `tmux new-session ... \; run-shell`, which the old regex exempted. --- scripts/hooks/pre-bash-dev-server-block.js | 55 +++++++++++-------- tests/hooks/pre-bash-dev-server-block.test.js | 18 +++++- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 5dcaddb56..7d57a354b 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -170,8 +170,9 @@ const TMUX_PANE_COMMANDS = new Set([ 'respawn-window', 'respawnw', 'send-keys', 'send' ]); -// Options that come before the tmux command and take a value. -const TMUX_OPTIONS_WITH_VALUE = new Set(['-f', '-L', '-S', '-T']); +// tmux's own options that take a value (`-L work`, `-Lwork`, or the last of a +// cluster such as `-vL work`). +const TMUX_OPTION_VALUE_LETTERS = new Set(['f', 'L', 'S', 'T']); // Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so // `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` / // `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches @@ -211,31 +212,39 @@ function collectCheckSegments(cmd) { } /** - * Return true when the tmux invocation that starts at `index` runs its command - * in a pane. The tmux command follows tmux's own options (`-L work`, `-f conf`), - * and the executable may come through a path or an env prefix, so this reads - * the parsed tokens instead of the start of the segment. + * Return true when the tmux invocation that starts at `index` runs the dev + * server in a pane. The tmux commands follow tmux's own options (`-L work`, + * `-f conf`), and the executable may come through a path or an env prefix, so + * this reads the parsed tokens instead of the start of the segment. A sequence + * (`new-session -d \; run-shell ...`) is exempt only when every command in it + * that runs the dev server is a pane command. */ function isTmuxPaneLaunch(segment, index) { - while (index < segment.length) { - const parsed = readToken(segment, index); - if (!parsed) return false; - index = parsed.end; - - const token = parsed.token; - if (!token) continue; - if (token === '-c') return false; - if (TMUX_OPTIONS_WITH_VALUE.has(token)) { - const value = readToken(segment, index); - if (!value) return false; - index = value.end; - continue; - } - if (isOptionToken(token)) continue; - return TMUX_PANE_COMMANDS.has(token); + const tokens = []; + for (let parsed = readToken(segment, index); parsed; parsed = readToken(segment, parsed.end)) { + if (parsed.token) tokens.push(parsed.token); } - return false; + let next = 0; + for (; next < tokens.length && isOptionToken(tokens[next]); next++) { + const letters = tokens[next].slice(1); + for (let i = 0; i < letters.length; i++) { + // -c runs its shell command without a pane. + if (letters[i] === 'c') return false; + if (TMUX_OPTION_VALUE_LETTERS.has(letters[i])) { + if (i === letters.length - 1) next++; + break; + } + } + } + + const commands = [[]]; + for (const token of tokens.slice(next)) { + if (token === ';') commands.push([]); + else commands[commands.length - 1].push(token); + } + const devCommands = commands.filter(command => DEV_PATTERN.test(command.join(' '))); + return devCommands.length > 0 && devCommands.every(command => TMUX_PANE_COMMANDS.has(command[0])); } function isBlockedDevSegment(segment) { diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 695a16846..799af5034 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -70,7 +70,17 @@ function runTests() { }) ? passed++ : failed++); (test('still blocks tmux run-shell and tmux -c — no pane keeps the log (exit 2)', () => { - for (const command of ['tmux run-shell "npm run dev"', 'tmux -L work run-shell "npm run dev"', 'tmux -c "npm run dev"']) { + for (const command of [ + 'tmux run-shell "npm run dev"', + 'tmux -L work run-shell "npm run dev"', + 'tmux -c "npm run dev"', + "tmux -c'npm run dev'", + // In -vL the L takes `new-session` as its socket name, so run-shell is the command. + 'tmux -vL new-session run-shell "npm run dev"', + // A pane command earlier in a sequence does not cover a later run-shell. + 'tmux new-session -d \\; run-shell "npm run dev"', + 'tmux -L work new-session -d \\; run-shell "npm run dev"' + ]) { const result = runScript(command); assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); } @@ -101,7 +111,11 @@ function runTests() { }) ? passed++ : failed++); (test('allows npm run dev sent to or respawned in a tmux pane (exit code 0)', () => { - for (const command of ['tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"']) { + for (const command of [ + 'tmux send-keys -t dev "npm run dev" Enter', + 'tmux respawn-pane -k -t dev "npm run dev"', + 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter' + ]) { const result = runScript(command); assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); } From eee00776be14d8e00705aa799bee3b4737192f7c Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 22:32:33 +0200 Subject: [PATCH 3/4] fix(hooks): end a tmux command at a semicolon that ends an argument tmux also separates commands at a `;` that ends an argument, so `new-session -d 'npm run dev;' run-shell 'npm run dev'` runs the second dev server without a pane. Split there as well. An escaped `\;` at the end of an argument stays literal, as in tmux. --- scripts/hooks/pre-bash-dev-server-block.js | 8 ++++++-- tests/hooks/pre-bash-dev-server-block.test.js | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 7d57a354b..ec86f112d 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -238,10 +238,14 @@ function isTmuxPaneLaunch(segment, index) { } } + // tmux ends a command at a `;` argument and at a `;` that ends an argument + // (`'npm run dev;'`); an escaped `\;` at the end stays literal. const commands = [[]]; for (const token of tokens.slice(next)) { - if (token === ';') commands.push([]); - else commands[commands.length - 1].push(token); + const ends = token.endsWith(';') && !token.endsWith('\\;'); + const value = ends ? token.slice(0, -1) : token; + if (value) commands[commands.length - 1].push(value); + if (ends) commands.push([]); } const devCommands = commands.filter(command => DEV_PATTERN.test(command.join(' '))); return devCommands.length > 0 && devCommands.every(command => TMUX_PANE_COMMANDS.has(command[0])); diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 799af5034..7427828f4 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -79,7 +79,9 @@ function runTests() { 'tmux -vL new-session run-shell "npm run dev"', // A pane command earlier in a sequence does not cover a later run-shell. 'tmux new-session -d \\; run-shell "npm run dev"', - 'tmux -L work new-session -d \\; run-shell "npm run dev"' + 'tmux -L work new-session -d \\; run-shell "npm run dev"', + // A `;` that ends an argument separates tmux commands too. + "tmux -L work new-session -d 'npm run dev;' run-shell 'npm run dev'" ]) { const result = runScript(command); assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); @@ -114,7 +116,8 @@ function runTests() { for (const command of [ 'tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"', - 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter' + 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter', + "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter" ]) { const result = runScript(command); assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); From 5311485e940d6dd1ad163f42a13b408bb95aaaa0 Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 22:45:28 +0200 Subject: [PATCH 4/4] fix(hooks): keep a backslash in double quotes as bash does in the dev-server block readToken dropped every backslash inside double quotes, while bash drops it only before $, `, ", \\ or a newline. `"echo ready\;"` therefore reached the tmux check as `echo ready;`, read as a command separator, and `tmux send-keys -t dev "echo ready\;" "npm run dev" Enter` was blocked although it types into a pane. --- scripts/hooks/pre-bash-dev-server-block.js | 4 +++- tests/hooks/pre-bash-dev-server-block.test.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index ec86f112d..c5c336e68 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -56,7 +56,9 @@ function readToken(input, startIndex) { continue; } - if (ch === '\\' && quote === '"' && index + 1 < input.length) { + // Inside double quotes, bash drops a backslash only before $ ` " \ or a + // newline, so `"echo ready\;"` keeps its `\;` for tmux. + if (ch === '\\' && quote === '"' && index + 1 < input.length && '$`"\\\n'.includes(input[index + 1])) { token += input[index + 1]; index += 2; continue; diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 7427828f4..a5d47c8e2 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -117,7 +117,9 @@ function runTests() { 'tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"', 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter', - "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter" + "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter", + // In double quotes bash keeps the backslash before ;, so tmux reads a literal ; + 'tmux send-keys -t dev "echo ready\\;" "npm run dev" Enter' ]) { const result = runScript(command); assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`);