From e13d14446dc13268113b90adc218c3fd3b0b9225 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 17:01:56 -0700 Subject: [PATCH 01/11] docs: brief the Windows-path-aware commandArgv0 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tokenizeCommand` treats `\` as a POSIX escape, so a Windows absolute path loses its separators before `commandArgv0` can take a basename: WATCHING rules never match a command invoked by full path, pane headers render `C:Program ...`, and `dor tool`'s take-over gate (PR #514) fails closed on `C:\bin\dor.cmd tool x`. Off main rather than the Dor Tools stack — the bug predates it, and the fix serves three consumers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD --- HANDOFF.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..dd694d25 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,109 @@ +# Handoff: a Windows-path-aware `commandArgv0` + +> Delete this file in the PR that does the work — it is a task brief, not a spec. + +Branch `windows-argv0`, based on `origin/main` (not on the Dor Tools stack: the +bug predates it and every fix here lands for `main` users immediately). + +## The bug + +`tokenizeCommand` in +[`lib/src/lib/terminal-state.ts`](lib/src/lib/terminal-state.ts) treats `\` as a +POSIX escape character, outside single quotes: + +```ts +if (char === '\\' && quote !== "'") { + escaping = true; + continue; +} +``` + +On a shell that reports Windows paths (PowerShell, cmd — Git Bash reports POSIX +paths and is unaffected), that eats the path separators before anything can +split on them: + +| OSC 633 command line | tokens today | `commandArgv0` today | wanted | +| --- | --- | --- | --- | +| `C:\tools\dor.cmd tool storybook` | `['C:toolsdor.cmd', 'tool', …]` | `C:toolsdor.cmd` | `dor.cmd` | +| `C:\Program Files\nodejs\npm.cmd run dev` | `['C:Program', 'Filesnodejsnpm.cmd', …]` | `C:Program` | `npm.cmd` | +| `C:\Users\me\.claude\local\claude` | `['C:Usersme.claudelocalclaude']` | `C:Usersme.claudelocalclaude` | `claude` | + +The basename split inside `commandArgv0` (`command.split(/[\\/]/)`) runs *after* +tokenizing, so by then there is no separator left to split on. + +## What it costs today + +Everything keyed on the program name silently misses when the user (or a +launcher, or a shim) invokes by absolute path on Windows: + +- **WATCHING rules never match.** `lib/src/lib/watched-commands.ts` stores bare + program names and `alert-manager.ts` compares them against + `commandArgv0(rawCommandLine)`. A user who added `claude` gets no alerts from + a Session started as `C:\Users\me\.claude\local\claude` — the alert simply + never rings, with nothing on screen to explain why (`docs/specs/alert.md` → + WATCHING). +- **Pane headers and the TODO dialog show mangled text.** + `TerminalPaneHeader.tsx:138` and `TodoAlertDialog.tsx:42` render `commandArgv0` + output; `summarizeCommandLine` (the `displayCommand` in + `docs/specs/terminal-state.md` → command lifecycle) runs the same tokenizer, + so a header reads `C:Program ...` instead of `npm run dev`. +- **`dor tool`'s take-over gate fails closed.** `isNakedToolInvocation` in + `lib/src/components/wall/tool-takeover.ts` (branch `tool-takeover`, PR #514) + checks `commandArgv0(line)` against the launcher names, so + `C:\bin\dor.cmd tool storybook` splits instead of taking over the pane. That + one is a placement miss, not a wrong action, and was accepted there precisely + so the fix could land here for every consumer at once — see the resolved + thread on `tool-takeover.ts:29` in PR #514. + +## Why it is not a one-line change + +The escape rule is load-bearing for POSIX shells, which is what everything else +reports: `foo\ bar` is one token containing a space, `find . -name \*.ts` passes +a literal `*`. Dropping the escape branch outright would regress those. + +## Suggested approach + +**Preferred — decide per backslash, inside the tokenizer.** A backslash is an +escape only when it precedes a character that shells actually escape +(whitespace, a quote, another backslash, or a glob/metacharacter); before a +path-ish character it is a literal separator. `C:\tools\dor.cmd` then survives +tokenizing intact and the existing `split(/[\\/]/)` basename does the rest, +while `foo\ bar` and `\*.ts` keep their current meaning. Self-contained: no +plumbing, and all three consumers above are fixed by the one change. + +**Alternative — carry the dialect.** `CwdState.pathKind` (`'posix' | 'windows' | +'unknown'`, already on every pane's state) says which dialect the shell speaks, +so `tokenizeCommand` could take it and switch escaping wholesale. More +principled, but it threads a parameter through `summarizeCommandLine`, +`commandArgv0`, `resolveCommandStart` and their callers, and the reported cwd is +not always present when a command line arrives. Prefer this only if the +per-character heuristic turns out to be ambiguous in practice. + +Whatever shape wins, keep **one** tokenizer: the gate in `tool-takeover.ts` +reuses `commandArgv0` / `primaryCommandTokens` exactly so the take-over gate and +the pane header can never disagree about one command line. + +## Scope checklist + +- [ ] `tokenizeCommand` in `lib/src/lib/terminal-state.ts`. +- [ ] Table-driven cases in `lib/src/lib/terminal-state.test.ts` next to the + existing `summarizeCommandLine` / `commandArgv0` cases: Windows absolute + paths with and without spaces, a `.cmd`/`.exe` launcher, plus the POSIX + escapes (`foo\ bar`, `\*.ts`) as regression pins. +- [ ] `lib/src/lib/terminal-prompt-shape.test.ts` fixtures use the maintainer's + real shell (`ntwigg@ntwigg-mac-2025`) — check nothing there depends on the + old mangling. +- [ ] Specs: this changes no documented rule, so the edit is likely limited to a + sentence in `docs/specs/terminal-state.md` if the tokenizer's dialect + handling is worth stating. Do **not** grow `alert.md` — the WATCHING rule + it documents is already "the bare program name"; this only makes the code + match it. Check the word budgets in `scripts/spec-word-budgets.json` if you + do add prose. +- [ ] `pnpm lint:specs` and the `lib` suite; no `dor` package changes expected. + +## Verification + +jsdom tests cover the parsing, but the payoff is on Windows. The repo has a +Windows CI lane (`Standalone Platform Check (windows-latest)`); a real check is +a PowerShell pane running an absolute-path command and confirming the header +shows the program name and a WATCHING rule on that name rings. From d2d2fbfcd53a03e56d066197c749d0c2cf3c83e5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 17:27:12 -0700 Subject: [PATCH 02/11] fix(terminal-state): keep Windows paths intact through the command tokenizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tokenizeCommand` treated every `\` outside single quotes as a POSIX escape, so a command line reported by PowerShell or cmd lost its path separators before `commandArgv0`'s basename split could see them: `C:\tools\dor.cmd` keyed as `C:toolsdor.cmd`. WATCHING rules stored under the bare program name never matched, and pane headers rendered the mangled text. A backslash now escapes only what a shell actually escapes — whitespace, a quote, another backslash, a glob/metacharacter — and is a literal separator otherwise, so `foo\ bar` and `\*.ts` keep their meaning. cmd.exe's unquoted program path with spaces (`C:\Program Files\nodejs\npm.cmd run dev`) is re-joined afterwards, but only when the join lands on an executable suffix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KzMCg7c37NRvdLEhpwmPpz --- HANDOFF.md | 109 --------------------------- docs/specs/terminal-state.md | 1 + lib/src/lib/terminal-state.test.ts | 30 ++++++++ lib/src/lib/terminal-state.ts | 44 ++++++++++- lib/src/lib/watched-commands.test.ts | 15 ++-- scripts/spec-word-budgets.json | 2 +- 6 files changed, 81 insertions(+), 120 deletions(-) delete mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index dd694d25..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,109 +0,0 @@ -# Handoff: a Windows-path-aware `commandArgv0` - -> Delete this file in the PR that does the work — it is a task brief, not a spec. - -Branch `windows-argv0`, based on `origin/main` (not on the Dor Tools stack: the -bug predates it and every fix here lands for `main` users immediately). - -## The bug - -`tokenizeCommand` in -[`lib/src/lib/terminal-state.ts`](lib/src/lib/terminal-state.ts) treats `\` as a -POSIX escape character, outside single quotes: - -```ts -if (char === '\\' && quote !== "'") { - escaping = true; - continue; -} -``` - -On a shell that reports Windows paths (PowerShell, cmd — Git Bash reports POSIX -paths and is unaffected), that eats the path separators before anything can -split on them: - -| OSC 633 command line | tokens today | `commandArgv0` today | wanted | -| --- | --- | --- | --- | -| `C:\tools\dor.cmd tool storybook` | `['C:toolsdor.cmd', 'tool', …]` | `C:toolsdor.cmd` | `dor.cmd` | -| `C:\Program Files\nodejs\npm.cmd run dev` | `['C:Program', 'Filesnodejsnpm.cmd', …]` | `C:Program` | `npm.cmd` | -| `C:\Users\me\.claude\local\claude` | `['C:Usersme.claudelocalclaude']` | `C:Usersme.claudelocalclaude` | `claude` | - -The basename split inside `commandArgv0` (`command.split(/[\\/]/)`) runs *after* -tokenizing, so by then there is no separator left to split on. - -## What it costs today - -Everything keyed on the program name silently misses when the user (or a -launcher, or a shim) invokes by absolute path on Windows: - -- **WATCHING rules never match.** `lib/src/lib/watched-commands.ts` stores bare - program names and `alert-manager.ts` compares them against - `commandArgv0(rawCommandLine)`. A user who added `claude` gets no alerts from - a Session started as `C:\Users\me\.claude\local\claude` — the alert simply - never rings, with nothing on screen to explain why (`docs/specs/alert.md` → - WATCHING). -- **Pane headers and the TODO dialog show mangled text.** - `TerminalPaneHeader.tsx:138` and `TodoAlertDialog.tsx:42` render `commandArgv0` - output; `summarizeCommandLine` (the `displayCommand` in - `docs/specs/terminal-state.md` → command lifecycle) runs the same tokenizer, - so a header reads `C:Program ...` instead of `npm run dev`. -- **`dor tool`'s take-over gate fails closed.** `isNakedToolInvocation` in - `lib/src/components/wall/tool-takeover.ts` (branch `tool-takeover`, PR #514) - checks `commandArgv0(line)` against the launcher names, so - `C:\bin\dor.cmd tool storybook` splits instead of taking over the pane. That - one is a placement miss, not a wrong action, and was accepted there precisely - so the fix could land here for every consumer at once — see the resolved - thread on `tool-takeover.ts:29` in PR #514. - -## Why it is not a one-line change - -The escape rule is load-bearing for POSIX shells, which is what everything else -reports: `foo\ bar` is one token containing a space, `find . -name \*.ts` passes -a literal `*`. Dropping the escape branch outright would regress those. - -## Suggested approach - -**Preferred — decide per backslash, inside the tokenizer.** A backslash is an -escape only when it precedes a character that shells actually escape -(whitespace, a quote, another backslash, or a glob/metacharacter); before a -path-ish character it is a literal separator. `C:\tools\dor.cmd` then survives -tokenizing intact and the existing `split(/[\\/]/)` basename does the rest, -while `foo\ bar` and `\*.ts` keep their current meaning. Self-contained: no -plumbing, and all three consumers above are fixed by the one change. - -**Alternative — carry the dialect.** `CwdState.pathKind` (`'posix' | 'windows' | -'unknown'`, already on every pane's state) says which dialect the shell speaks, -so `tokenizeCommand` could take it and switch escaping wholesale. More -principled, but it threads a parameter through `summarizeCommandLine`, -`commandArgv0`, `resolveCommandStart` and their callers, and the reported cwd is -not always present when a command line arrives. Prefer this only if the -per-character heuristic turns out to be ambiguous in practice. - -Whatever shape wins, keep **one** tokenizer: the gate in `tool-takeover.ts` -reuses `commandArgv0` / `primaryCommandTokens` exactly so the take-over gate and -the pane header can never disagree about one command line. - -## Scope checklist - -- [ ] `tokenizeCommand` in `lib/src/lib/terminal-state.ts`. -- [ ] Table-driven cases in `lib/src/lib/terminal-state.test.ts` next to the - existing `summarizeCommandLine` / `commandArgv0` cases: Windows absolute - paths with and without spaces, a `.cmd`/`.exe` launcher, plus the POSIX - escapes (`foo\ bar`, `\*.ts`) as regression pins. -- [ ] `lib/src/lib/terminal-prompt-shape.test.ts` fixtures use the maintainer's - real shell (`ntwigg@ntwigg-mac-2025`) — check nothing there depends on the - old mangling. -- [ ] Specs: this changes no documented rule, so the edit is likely limited to a - sentence in `docs/specs/terminal-state.md` if the tokenizer's dialect - handling is worth stating. Do **not** grow `alert.md` — the WATCHING rule - it documents is already "the bare program name"; this only makes the code - match it. Check the word budgets in `scripts/spec-word-budgets.json` if you - do add prose. -- [ ] `pnpm lint:specs` and the `lib` suite; no `dor` package changes expected. - -## Verification - -jsdom tests cover the parsing, but the payoff is on Windows. The repo has a -Windows CI lane (`Standalone Platform Check (windows-latest)`); a real check is -a PowerShell pane running an absolute-path command and confirming the header -shows the program name and a WATCHING rule on that name rings. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 8afa31b8..2611152a 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -96,6 +96,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `commandLine` stores `pendingCommandLine`. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, uses `event.startedAt` when present, clears `pendingCommandLine`, and sets `{ kind: "running" }`. `displayCommand` is the summarized pending command line; when none is pending (`OSC 133 ; C` carries no command), it falls back to the newest non-user title candidate, then to the literal `shell`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`. +- `displayCommand` and the `commandArgv0` WATCHING key (`docs/specs/alert.md`) share one tokenizer, so they can never disagree about a command line. `\` escapes only before a character a shell escapes, so a native Windows program path survives to the basename split; an unquoted one with spaces is re-joined only when the join ends in an executable suffix. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources. ### Keystroke fallback diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index d59572ad..f1e88ee1 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -15,6 +15,7 @@ import { notificationDisplayTitle, reduceTerminalState, shortestUniqueCwdLabels, + commandArgv0, summarizeCommandLine, surfaceRunsCommand, terminalTitleFromNotification, @@ -273,6 +274,35 @@ describe('command title summarizer', () => { }); }); +describe('command tokenizer dialects', () => { + // A backslash is a path separator unless it precedes something a shell really + // escapes, so both dialects reduce to the bare program name. + it.each([ + // Windows: absolute paths, launchers, quoted and unquoted spaces, UNC. + ['C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + ['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'], + ['C:\\Program Files\\nodejs\\npm.cmd run dev', 'npm.cmd', 'npm.cmd run dev'], + ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], + ['c:/tools/dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], + ['FOO=1 C:\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], + // POSIX escapes keep their meaning. + ['/opt/my\\ tools/claude --print', 'claude', 'claude --print'], + ['find . -name \\*.ts', 'find', 'find . -name'], + ['echo a\\\\b', 'echo', 'echo a\\b'], + ])('reduces %j to %j / %j', (raw, argv0, summary) => { + expect(commandArgv0(raw)).toBe(argv0); + expect(summarizeCommandLine(raw)).toBe(summary); + }); + + it('only re-joins an unquoted Windows program path that lands on an executable', () => { + // Two real words, not one path with a space: the second token starts its own + // absolute path, so the split stands and argv0 stays the program. + expect(commandArgv0('C:\\bin\\tool C:\\data\\in.txt')).toBe('tool'); + expect(commandArgv0('C:\\bin\\tool sub\\dir\\notes.txt')).toBe('tool'); + }); +}); + describe('header and grouping derivation', () => { it('uses for terminals without a foreground command', () => { const pane = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/app', 1)!, activity: { kind: 'editing' } }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 862c7bc1..55115f09 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -782,6 +782,18 @@ function withRequiredHostPrefixes( return result; } +// Characters a shell actually escapes with a backslash. Everything else after a +// `\` is a literal, so a native Windows path survives tokenizing intact and the +// argv0 basename split below still has separators to split on. The escape rule +// stays load-bearing for the POSIX shells that report most command lines: +// `foo\ bar` is one token and `\*.ts` passes a literal glob. +const SHELL_ESCAPABLE = /[\s"'\\*?[\]{}()$`!|&;<>#]/; + +/** + * Split a command line into words, honoring quotes, POSIX backslash escapes + * (see `SHELL_ESCAPABLE`), and the pipeline/compound separators `| || && ; &`, + * which are emitted as their own tokens. + */ function tokenizeCommand(input: string): string[] { const tokens: string[] = []; let current = ''; @@ -803,7 +815,12 @@ function tokenizeCommand(input: string): string[] { continue; } if (char === '\\' && quote !== "'") { - escaping = true; + const next = input[i + 1]; + if (next !== undefined && SHELL_ESCAPABLE.test(next)) { + escaping = true; + continue; + } + current += char; continue; } if (quote) { @@ -843,6 +860,29 @@ function tokenizeCommand(input: string): string[] { return tokens; } +const WINDOWS_PATH_HEAD = /^[A-Za-z]:[\\/]/; +const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; + +/** + * cmd.exe resolves an unquoted program path containing spaces by probing + * successively longer prefixes, so `C:\Program Files\nodejs\npm.cmd run dev` + * is one program plus two arguments. Tokenizing cannot know that, so re-join the + * leading tokens — but only when the join lands on a Windows executable suffix, + * which keeps `C:\bin\tool C:\data\in.txt` (two real words) split. + */ +function joinWindowsProgramPath(tokens: string[]): string[] { + const head = tokens[0]; + if (!head || !WINDOWS_PATH_HEAD.test(head) || WINDOWS_EXECUTABLE_SUFFIX.test(head)) return tokens; + let joined = head; + for (let i = 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (!token.includes('\\') || WINDOWS_PATH_HEAD.test(token)) break; + joined = `${joined} ${token}`; + if (WINDOWS_EXECUTABLE_SUFFIX.test(token)) return [joined, ...tokens.slice(i + 1)]; + } + return tokens; +} + function takePrimaryCommandTokens(tokens: string[]): string[] { const firstBoundary = tokens.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&'); const command = (firstBoundary === -1 ? tokens : tokens.slice(0, firstBoundary)).filter(Boolean); @@ -852,7 +892,7 @@ function takePrimaryCommandTokens(tokens: string[]): string[] { index += 1; while (isEnvAssignment(command[index])) index += 1; } - return command.slice(index); + return joinWindowsProgramPath(command.slice(index)); } function isEnvAssignment(token: string | undefined): boolean { diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index 1f208f4f..245d1a78 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -47,14 +47,13 @@ describe('commandArgv0', () => { expect(commandArgv0(raw)).toBeNull(); }); - it('mangles an unquoted native Windows path, and that is a known limitation', () => { - // The shared tokenizer reads `\` as a POSIX escape, so backslash separators - // are eaten before the basename split can see them — `summarizeCommandLine` - // has always had the same blind spot. Harmless in practice: the shells that - // report a command line (pwsh, Git Bash, WSL) send either a bare program - // name or a POSIX path, and the mangling is at least stable, so a rule keyed - // on it still matches itself. - expect(commandArgv0('C:\\tools\\claude.exe --print')).toBe('C:toolsclaude.exe'); + // Native Windows paths reduce to the same bare name a rule is stored under; + // the tokenizer's dialect handling is pinned in `terminal-state.test.ts`. + it.each([ + ['C:\\tools\\claude.exe --print', 'claude.exe'], + ['C:\\Users\\me\\.claude\\local\\claude', 'claude'], + ])('reduces the Windows path %j to %j', (raw, expected) => { + expect(commandArgv0(raw)).toBe(expected); }); }); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index bad33951..102281f4 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/standalone.rationale.md": 375, "docs/specs/terminal-escapes.md": 3950, "docs/specs/terminal-escapes.rationale.md": 350, - "docs/specs/terminal-state.md": 2725, + "docs/specs/terminal-state.md": 2780, "docs/specs/theme.md": 2350, "docs/specs/tiling-engine.md": 5175, "docs/specs/tiling-engine.rationale.md": 900, From 64e5728a1335b3312d9e5982ca120291583382c5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 17:35:13 -0700 Subject: [PATCH 03/11] refactor(terminal-state): share the escapable set, and honor PowerShell's `&` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify follow-ups to the tokenizer fix: - The tokenizer's escapable set was a second copy of `shell-escape.ts`'s `POSIX_UNSAFE`, missing `~` — so a path Dormouse itself escaped for paste (`\~/my\ app`) no longer read back as itself. Export the class as `POSIX_ESCAPABLE` and unescape exactly what it escapes; a table test pins the round trip character by character. - `& "C:\Program Files\nodejs\npm.cmd" run dev` — the only way PowerShell runs a quoted program path — yielded `null` from `commandArgv0`, because a bare `&` at index 0 read as a boundary that left no command. Drop a leading `&`; it is never a POSIX background suffix. - Dedupe: one `commandBasename`, one `WINDOWS_DRIVE_PREFIX`, one `WINDOWS_EXECUTABLE_SUFFIX` in place of three, two, and two copies. - Tighten the tests that pinned nothing, and cut the spec addition to the one cross-file rule (the shared escape set) it needs to state. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KzMCg7c37NRvdLEhpwmPpz --- docs/specs/terminal-state.md | 3 +- lib/src/lib/shell-escape.ts | 17 +++++-- lib/src/lib/terminal-state.test.ts | 24 +++++++--- lib/src/lib/terminal-state.ts | 68 ++++++++++++++++++---------- lib/src/lib/watched-commands.test.ts | 10 ++-- scripts/spec-word-budgets.json | 2 +- 6 files changed, 79 insertions(+), 45 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 2611152a..1232edc9 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -96,9 +96,10 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `commandLine` stores `pendingCommandLine`. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, uses `event.startedAt` when present, clears `pendingCommandLine`, and sets `{ kind: "running" }`. `displayCommand` is the summarized pending command line; when none is pending (`OSC 133 ; C` carries no command), it falls back to the newest non-user title candidate, then to the literal `shell`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`. -- `displayCommand` and the `commandArgv0` WATCHING key (`docs/specs/alert.md`) share one tokenizer, so they can never disagree about a command line. `\` escapes only before a character a shell escapes, so a native Windows program path survives to the basename split; an unquoted one with spaces is re-joined only when the join ends in an executable suffix. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources. +Command-line tokenizing is dialect-free: `\` escapes only what `shellEscapePosix` escapes (`lib/src/lib/shell-escape.ts` owns the set, so the two must stay in sync), which leaves the separators of a native Windows program path intact for the basename step. + ### Keystroke fallback For shells without OSC 133/633 integration, the command is read from what is on screen rather than reconstructed from keystrokes. diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index 7b2bdc1b..37f59a67 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -1,10 +1,17 @@ import { quotePowerShellArg, type ShellCommandKind } from 'dor/commands/shell-quote'; -// Matches macOS Terminal's drag-and-drop format: backslash-escape each shell -// metacharacter instead of wrapping in quotes. TUIs like `claude` recognize -// backslash-escaped tokens as filesystem paths where a single-quoted whole -// path gets treated as opaque pasted text. -const POSIX_UNSAFE = /([ \t!"#$&'()*;<>?[\\\]`{|}~])/g; +/** + * The shell metacharacters `shellEscapePosix` backslash-escapes, matching macOS + * Terminal's drag-and-drop format: escape each one instead of wrapping in + * quotes. TUIs like `claude` recognize backslash-escaped tokens as filesystem + * paths where a single-quoted whole path gets treated as opaque pasted text. + * + * `tokenizeCommand` in `terminal-state.ts` reads the same set back, so a path + * Dormouse escaped for paste round-trips through Dormouse's own command + * tokenizer; `terminal-state.test.ts` -> "command tokenizer dialects" pins it. + */ +export const POSIX_ESCAPABLE = /[ \t!"#$&'()*;<>?[\\\]`{|}~]/; +const POSIX_UNSAFE = new RegExp(`(${POSIX_ESCAPABLE.source})`, 'g'); const POSIX_NEEDS_QUOTES = /[\n\r]/; export function shellEscapePosix(input: string): string { diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index f1e88ee1..3b4a0179 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { shellEscapePosix } from './shell-escape'; import { + commandArgv0, createTerminalPaneState, cwdDisplay, cwdFromManualPath, @@ -15,7 +17,6 @@ import { notificationDisplayTitle, reduceTerminalState, shortestUniqueCwdLabels, - commandArgv0, summarizeCommandLine, surfaceRunsCommand, terminalTitleFromNotification, @@ -283,22 +284,31 @@ describe('command tokenizer dialects', () => { ['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'], ['C:\\Program Files\\nodejs\\npm.cmd run dev', 'npm.cmd', 'npm.cmd run dev'], ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], - ['c:/tools/dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + ['FOO=1 C:\\Program Files\\nodejs\\npm.cmd run dev', 'npm.cmd', 'npm.cmd run dev'], ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], - ['FOO=1 C:\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], + // PowerShell's call operator, the only way that shell runs a quoted path. + ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], + ['& C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], // POSIX escapes keep their meaning. ['/opt/my\\ tools/claude --print', 'claude', 'claude --print'], - ['find . -name \\*.ts', 'find', 'find . -name'], + ['grep \\*.ts src', 'grep', 'grep *.ts src'], ['echo a\\\\b', 'echo', 'echo a\\b'], ])('reduces %j to %j / %j', (raw, argv0, summary) => { expect(commandArgv0(raw)).toBe(argv0); expect(summarizeCommandLine(raw)).toBe(summary); }); + // Pins the `POSIX_ESCAPABLE` contract: every character `shellEscapePosix` + // writes for a drag-and-drop paste, the tokenizer reads back unchanged. + it.each(Array.from(` \t!"#$&'()*;<>?[]\`{|}~\\`))('round-trips %j out of shellEscapePosix', (char) => { + expect(summarizeCommandLine(`cat ${shellEscapePosix(`a${char}b`)}`)).toBe(`cat a${char}b`); + }); + it('only re-joins an unquoted Windows program path that lands on an executable', () => { - // Two real words, not one path with a space: the second token starts its own - // absolute path, so the split stands and argv0 stays the program. - expect(commandArgv0('C:\\bin\\tool C:\\data\\in.txt')).toBe('tool'); + // Two real words, not one path with a space. The second token starting its + // own absolute path stops the join even when it ends in an executable... + expect(commandArgv0('C:\\bin\\tool C:\\data\\run.cmd')).toBe('tool'); + // ...and a relative continuation still has to reach an executable suffix. expect(commandArgv0('C:\\bin\\tool sub\\dir\\notes.txt')).toBe('tool'); }); }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 55115f09..6d0b5a90 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -1,3 +1,5 @@ +import { POSIX_ESCAPABLE } from './shell-escape'; + export type CwdSource = 'osc7' | 'osc9_9' | 'osc633' | 'osc1337' | 'process' | 'manual'; export type PathKind = 'posix' | 'windows' | 'unknown'; @@ -387,7 +389,7 @@ export function commandArgv0(raw: string): string | null { const commandTokens = takePrimaryCommandTokens(tokenizeCommand(raw.trim())); const command = commandTokens[0]; if (!command) return null; - return command.split(/[\\/]/).pop() || null; + return commandBasename(command) || null; } export interface ResolvedCommandStart { @@ -436,7 +438,7 @@ export function resolveCommandStart( // already-equal paths stay equal. function canonicalizeCwdForMatch(path: string): string { const withDrive = path.replace(/^\/([A-Za-z])\//, (_match, drive: string) => `${drive}:/`); - if (!/^[A-Za-z]:[\\/]/.test(withDrive)) return path; + if (!WINDOWS_DRIVE_PREFIX.test(withDrive)) return path; const unified = withDrive.replace(/\//g, '\\'); return unified.charAt(0).toUpperCase() + unified.slice(1); } @@ -679,6 +681,10 @@ function inferPathKind(path: string): PathKind { return 'unknown'; } +// A drive letter plus a separator. Narrower than `isWindowsPath`, which also +// accepts a bare `C:` and the UNC forms. +const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:[\\/]/; + function isWindowsPath(path: string): boolean { return /^[A-Za-z]:(?:[\\/]|$)/.test(path) || isUncPath(path); } @@ -782,17 +788,17 @@ function withRequiredHostPrefixes( return result; } -// Characters a shell actually escapes with a backslash. Everything else after a -// `\` is a literal, so a native Windows path survives tokenizing intact and the -// argv0 basename split below still has separators to split on. The escape rule -// stays load-bearing for the POSIX shells that report most command lines: -// `foo\ bar` is one token and `\*.ts` passes a literal glob. -const SHELL_ESCAPABLE = /[\s"'\\*?[\]{}()$`!|&;<>#]/; - /** - * Split a command line into words, honoring quotes, POSIX backslash escapes - * (see `SHELL_ESCAPABLE`), and the pipeline/compound separators `| || && ; &`, - * which are emitted as their own tokens. + * Split a command line into words, honoring quotes, POSIX backslash escapes, + * and the pipeline/compound separators `| || && ; &`, which are emitted as + * their own tokens. + * + * A `\` escapes only whitespace or a `POSIX_ESCAPABLE` metacharacter (`foo\ bar` + * is one token, `\*.ts` passes a literal glob); before anything else it is a + * literal, so a native Windows program path survives tokenizing intact and + * `commandBasename` still has separators to split on. A Windows segment that + * does start with a metacharacter (`C:\$Recycle.Bin`) is the accepted cost of + * sharing one set with `shellEscapePosix`. */ function tokenizeCommand(input: string): string[] { const tokens: string[] = []; @@ -816,7 +822,7 @@ function tokenizeCommand(input: string): string[] { } if (char === '\\' && quote !== "'") { const next = input[i + 1]; - if (next !== undefined && SHELL_ESCAPABLE.test(next)) { + if (next !== undefined && (/\s/.test(next) || POSIX_ESCAPABLE.test(next))) { escaping = true; continue; } @@ -860,23 +866,27 @@ function tokenizeCommand(input: string): string[] { return tokens; } -const WINDOWS_PATH_HEAD = /^[A-Za-z]:[\\/]/; const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; /** * cmd.exe resolves an unquoted program path containing spaces by probing * successively longer prefixes, so `C:\Program Files\nodejs\npm.cmd run dev` - * is one program plus two arguments. Tokenizing cannot know that, so re-join the - * leading tokens — but only when the join lands on a Windows executable suffix, - * which keeps `C:\bin\tool C:\data\in.txt` (two real words) split. + * is one program plus two arguments — a shape only cmd.exe accepts, and one the + * keystroke fallback reads straight off a `C:\...>` prompt. Tokenizing cannot know that, so re-join + * across directory boundaries — each continuation must still carry a separator + * — and commit only when the join lands on a Windows executable suffix. + * + * A space inside the *final* segment (`C:\Tools\My Program.exe`) therefore + * stays split, deliberately: `C:\bin\run test.exe` is the same shape and far + * more likely to be a program plus an argument. */ function joinWindowsProgramPath(tokens: string[]): string[] { const head = tokens[0]; - if (!head || !WINDOWS_PATH_HEAD.test(head) || WINDOWS_EXECUTABLE_SUFFIX.test(head)) return tokens; + if (!head || !WINDOWS_DRIVE_PREFIX.test(head) || WINDOWS_EXECUTABLE_SUFFIX.test(head)) return tokens; let joined = head; for (let i = 1; i < tokens.length; i += 1) { const token = tokens[i]; - if (!token.includes('\\') || WINDOWS_PATH_HEAD.test(token)) break; + if (!token.includes('\\') || WINDOWS_DRIVE_PREFIX.test(token)) break; joined = `${joined} ${token}`; if (WINDOWS_EXECUTABLE_SUFFIX.test(token)) return [joined, ...tokens.slice(i + 1)]; } @@ -884,8 +894,13 @@ function joinWindowsProgramPath(tokens: string[]): string[] { } function takePrimaryCommandTokens(tokens: string[]): string[] { - const firstBoundary = tokens.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&'); - const command = (firstBoundary === -1 ? tokens : tokens.slice(0, firstBoundary)).filter(Boolean); + // PowerShell's call operator. `& "C:\Program Files\nodejs\npm.cmd" run dev` + // is the only way that shell runs a quoted program path, and a leading `&` is + // never a POSIX background suffix, so drop it rather than read it as a + // boundary that leaves no command at all. + const words = tokens[0] === '&' ? tokens.slice(1) : tokens; + const firstBoundary = words.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&'); + const command = (firstBoundary === -1 ? words : words.slice(0, firstBoundary)).filter(Boolean); let index = 0; while (isEnvAssignment(command[index])) index += 1; if (command[index] === 'env') { @@ -899,10 +914,15 @@ function isEnvAssignment(token: string | undefined): boolean { return !!token && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token); } +/** argv[0] reduced to a bare program name, in either path dialect. */ +function commandBasename(command: string): string { + return command.split(/[\\/]/).pop() ?? command; +} + function commandTitleTokens(tokens: string[]): string[] { const command = tokens[0]; if (!command) return []; - const basename = command.split(/[\\/]/).pop() ?? command; + const basename = commandBasename(command); const rest = tokens.slice(1); if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; @@ -983,9 +1003,9 @@ const GENERIC_PROCESS_TITLE_NAMES = new Set([ function isGenericProcessTitle(title: string): boolean { const trimmed = title.trim(); if (!trimmed) return false; - const basename = trimmed.split(/[\\/]/).pop() ?? trimmed; + const basename = commandBasename(trimmed); if (/\s/.test(basename)) return false; // carries arguments/description → meaningful - if (/\.(?:exe|com|bat|cmd|ps1)$/i.test(basename)) return true; // bare executable path + if (WINDOWS_EXECUTABLE_SUFFIX.test(basename)) return true; // bare executable path return GENERIC_PROCESS_TITLE_NAMES.has(basename.toLowerCase()); // bare shell/interpreter name } diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index 245d1a78..09200ceb 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -47,13 +47,9 @@ describe('commandArgv0', () => { expect(commandArgv0(raw)).toBeNull(); }); - // Native Windows paths reduce to the same bare name a rule is stored under; - // the tokenizer's dialect handling is pinned in `terminal-state.test.ts`. - it.each([ - ['C:\\tools\\claude.exe --print', 'claude.exe'], - ['C:\\Users\\me\\.claude\\local\\claude', 'claude'], - ])('reduces the Windows path %j to %j', (raw, expected) => { - expect(commandArgv0(raw)).toBe(expected); + it('reduces a native Windows path to the bare name a rule is stored under', () => { + // The tokenizer's dialect handling is pinned in `terminal-state.test.ts`. + expect(commandArgv0('C:\\Users\\me\\.claude\\local\\claude')).toBe('claude'); }); }); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 102281f4..27148c24 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/standalone.rationale.md": 375, "docs/specs/terminal-escapes.md": 3950, "docs/specs/terminal-escapes.rationale.md": 350, - "docs/specs/terminal-state.md": 2780, + "docs/specs/terminal-state.md": 2760, "docs/specs/theme.md": 2350, "docs/specs/tiling-engine.md": 5175, "docs/specs/tiling-engine.rationale.md": 900, From ea3654588007234c99ea68b584683f6431be217b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 17:48:00 -0700 Subject: [PATCH 04/11] fix(terminal-state): drop the unquoted-Windows-path join; strip launcher suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /code-review found `joinWindowsProgramPath` returning the *wrong* argv0, not just a miss: `"C:\Program Files\Git\bin\bash" scripts\bootstrap.cmd` reduced to `bootstrap.cmd`, because the tokenizer discards quoting and the join could not tell a complete program path from one continued by an argument. Unquoted is no better — `C:\tools\node .\scripts\build.cmd` gave `build.cmd`. `A\B C\D.cmd` is genuinely `A\B` plus an argument as often as it is one path, and only the filesystem can say which; cmd.exe resolves it by probing, we cannot. It also missed every shape it was supposed to catch (`Program Files (x86)\Microsoft VS Code`, extension-less `Program Files\nodejs\node`, forward-slash drive paths). An unquoted Windows path with spaces now stays split — a miss, never a wrong program name. The quoted form PowerShell requires works from the escape fix alone. Also from the review: - `.cmd`/`.exe` is how the same program spells itself on Windows, so strip it before matching in `commandTitleTokens`. This PR is what makes those the normal Windows basename, and every per-program case is keyed on a bare name, so `vim.exe f` was rendering `vim.exe f` and `npm.cmd run dev` was missing the npm case. - WATCHING keys written by the old mangling (`C:toolsclaude.exe`) can never match again. `normalize` now drops any key holding a separator or `:` — no basename ever does — and both the stored copy and the host snapshot go through it. - `\s` in the escape test was redundant with `POSIX_ESCAPABLE`'s own space and tab, and made the shared-set claim untrue. - `commandBasename`'s `?? command` was unreachable; use a regex with no dead branch. Note the POSIX fidelity cost (`grep \-v` keeps its backslash) beside the Windows one. - The round-trip test asserted a tautology. It now pins the half that matters: no path character is in the escapable set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KzMCg7c37NRvdLEhpwmPpz --- docs/specs/terminal-state.md | 2 +- lib/src/lib/terminal-state.test.ts | 47 ++++++++++++++-------- lib/src/lib/terminal-state.ts | 60 +++++++++------------------- lib/src/lib/watched-commands.test.ts | 7 ++++ lib/src/lib/watched-commands.ts | 21 +++++++--- scripts/spec-word-budgets.json | 2 +- 6 files changed, 73 insertions(+), 66 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 1232edc9..3faddbfa 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -98,7 +98,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources. -Command-line tokenizing is dialect-free: `\` escapes only what `shellEscapePosix` escapes (`lib/src/lib/shell-escape.ts` owns the set, so the two must stay in sync), which leaves the separators of a native Windows program path intact for the basename step. +Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/shell-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. ### Keystroke fallback diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 3b4a0179..1a3d467a 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { shellEscapePosix } from './shell-escape'; +import { POSIX_ESCAPABLE, shellEscapePosix } from './shell-escape'; import { commandArgv0, createTerminalPaneState, @@ -268,6 +268,12 @@ describe('command title summarizer', () => { expect(summarizeCommandLine('ssh prod-box')).toBe('ssh prod-box'); }); + it('matches its per-program cases through a Windows launcher suffix', () => { + expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim'); + expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo watch -x test'); + expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm'); + }); + it('keeps pipelines and compound commands recognizable', () => { expect(summarizeCommandLine('cat package.json | jq .name')).toBe('cat package.json | ...'); expect(summarizeCommandLine('cd lib && pnpm test')).toBe('cd lib ...'); @@ -279,16 +285,16 @@ describe('command tokenizer dialects', () => { // A backslash is a path separator unless it precedes something a shell really // escapes, so both dialects reduce to the bare program name. it.each([ - // Windows: absolute paths, launchers, quoted and unquoted spaces, UNC. - ['C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + // Windows: absolute paths, launchers, a quoted path with spaces. + ['C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor tool storybook'], ['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'], - ['C:\\Program Files\\nodejs\\npm.cmd run dev', 'npm.cmd', 'npm.cmd run dev'], - ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], - ['FOO=1 C:\\Program Files\\nodejs\\npm.cmd run dev', 'npm.cmd', 'npm.cmd run dev'], - ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], + ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm run dev'], + ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude --print'], + ['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm run dev'], // PowerShell's call operator, the only way that shell runs a quoted path. - ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], - ['& C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + // Without the leading-`&` skip it reads as a boundary and argv0 is null. + ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm run dev'], + ['& C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor tool storybook'], // POSIX escapes keep their meaning. ['/opt/my\\ tools/claude --print', 'claude', 'claude --print'], ['grep \\*.ts src', 'grep', 'grep *.ts src'], @@ -298,18 +304,25 @@ describe('command tokenizer dialects', () => { expect(summarizeCommandLine(raw)).toBe(summary); }); - // Pins the `POSIX_ESCAPABLE` contract: every character `shellEscapePosix` - // writes for a drag-and-drop paste, the tokenizer reads back unchanged. + // An unquoted Windows path with spaces is undecidable without probing the + // filesystem — `A\B C\D.cmd` is equally `A\B` plus an argument — so the + // tokenizer splits it and argv0 misses rather than naming the wrong program. + it('leaves an unquoted Windows path with spaces split', () => { + expect(commandArgv0('C:\\Program Files\\nodejs\\npm.cmd run dev')).toBe('Program'); + expect(commandArgv0('"C:\\Program Files\\Git\\bin\\bash" scripts\\bootstrap.cmd')).toBe('bash'); + }); + + // `POSIX_ESCAPABLE` is `shellEscapePosix`'s set; the tokenizer unescapes it. + // The two halves must name the same characters or a path Dormouse escaped for + // a drag-and-drop paste renders with stray backslashes in the pane header. it.each(Array.from(` \t!"#$&'()*;<>?[]\`{|}~\\`))('round-trips %j out of shellEscapePosix', (char) => { + expect(POSIX_ESCAPABLE.test(char)).toBe(true); expect(summarizeCommandLine(`cat ${shellEscapePosix(`a${char}b`)}`)).toBe(`cat a${char}b`); }); - it('only re-joins an unquoted Windows program path that lands on an executable', () => { - // Two real words, not one path with a space. The second token starting its - // own absolute path stops the join even when it ends in an executable... - expect(commandArgv0('C:\\bin\\tool C:\\data\\run.cmd')).toBe('tool'); - // ...and a relative continuation still has to reach an executable suffix. - expect(commandArgv0('C:\\bin\\tool sub\\dir\\notes.txt')).toBe('tool'); + it('holds no path character, which is what keeps Windows separators intact', () => { + const pathChars = Array.from('AZaz09_-.+=,@:^%'); + expect(pathChars.filter((char) => POSIX_ESCAPABLE.test(char))).toEqual([]); }); }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 6d0b5a90..80db3285 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -438,7 +438,7 @@ export function resolveCommandStart( // already-equal paths stay equal. function canonicalizeCwdForMatch(path: string): string { const withDrive = path.replace(/^\/([A-Za-z])\//, (_match, drive: string) => `${drive}:/`); - if (!WINDOWS_DRIVE_PREFIX.test(withDrive)) return path; + if (!/^[A-Za-z]:[\\/]/.test(withDrive)) return path; const unified = withDrive.replace(/\//g, '\\'); return unified.charAt(0).toUpperCase() + unified.slice(1); } @@ -681,10 +681,6 @@ function inferPathKind(path: string): PathKind { return 'unknown'; } -// A drive letter plus a separator. Narrower than `isWindowsPath`, which also -// accepts a bare `C:` and the UNC forms. -const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:[\\/]/; - function isWindowsPath(path: string): boolean { return /^[A-Za-z]:(?:[\\/]|$)/.test(path) || isUncPath(path); } @@ -793,12 +789,14 @@ function withRequiredHostPrefixes( * and the pipeline/compound separators `| || && ; &`, which are emitted as * their own tokens. * - * A `\` escapes only whitespace or a `POSIX_ESCAPABLE` metacharacter (`foo\ bar` - * is one token, `\*.ts` passes a literal glob); before anything else it is a - * literal, so a native Windows program path survives tokenizing intact and - * `commandBasename` still has separators to split on. A Windows segment that - * does start with a metacharacter (`C:\$Recycle.Bin`) is the accepted cost of - * sharing one set with `shellEscapePosix`. + * A `\` escapes exactly the `POSIX_ESCAPABLE` set (`foo\ bar` is one token, + * `\*.ts` passes a literal glob, and a path Dormouse escaped for paste reads + * back as itself); before anything else it is a literal, so a native Windows + * program path survives tokenizing intact and `commandBasename` still has + * separators to split on. Two accepted costs of one dialect-free set: a Windows + * segment that starts with a metacharacter (`C:\$Recycle.Bin`) still loses its + * separator, and a POSIX escape of an ordinary character (`grep \-v`) keeps a + * backslash bash would drop. Both are display-only — neither reaches argv[0]. */ function tokenizeCommand(input: string): string[] { const tokens: string[] = []; @@ -822,7 +820,7 @@ function tokenizeCommand(input: string): string[] { } if (char === '\\' && quote !== "'") { const next = input[i + 1]; - if (next !== undefined && (/\s/.test(next) || POSIX_ESCAPABLE.test(next))) { + if (next !== undefined && POSIX_ESCAPABLE.test(next)) { escaping = true; continue; } @@ -866,33 +864,6 @@ function tokenizeCommand(input: string): string[] { return tokens; } -const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; - -/** - * cmd.exe resolves an unquoted program path containing spaces by probing - * successively longer prefixes, so `C:\Program Files\nodejs\npm.cmd run dev` - * is one program plus two arguments — a shape only cmd.exe accepts, and one the - * keystroke fallback reads straight off a `C:\...>` prompt. Tokenizing cannot know that, so re-join - * across directory boundaries — each continuation must still carry a separator - * — and commit only when the join lands on a Windows executable suffix. - * - * A space inside the *final* segment (`C:\Tools\My Program.exe`) therefore - * stays split, deliberately: `C:\bin\run test.exe` is the same shape and far - * more likely to be a program plus an argument. - */ -function joinWindowsProgramPath(tokens: string[]): string[] { - const head = tokens[0]; - if (!head || !WINDOWS_DRIVE_PREFIX.test(head) || WINDOWS_EXECUTABLE_SUFFIX.test(head)) return tokens; - let joined = head; - for (let i = 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (!token.includes('\\') || WINDOWS_DRIVE_PREFIX.test(token)) break; - joined = `${joined} ${token}`; - if (WINDOWS_EXECUTABLE_SUFFIX.test(token)) return [joined, ...tokens.slice(i + 1)]; - } - return tokens; -} - function takePrimaryCommandTokens(tokens: string[]): string[] { // PowerShell's call operator. `& "C:\Program Files\nodejs\npm.cmd" run dev` // is the only way that shell runs a quoted program path, and a leading `&` is @@ -907,7 +878,7 @@ function takePrimaryCommandTokens(tokens: string[]): string[] { index += 1; while (isEnvAssignment(command[index])) index += 1; } - return joinWindowsProgramPath(command.slice(index)); + return command.slice(index); } function isEnvAssignment(token: string | undefined): boolean { @@ -916,13 +887,18 @@ function isEnvAssignment(token: string | undefined): boolean { /** argv[0] reduced to a bare program name, in either path dialect. */ function commandBasename(command: string): string { - return command.split(/[\\/]/).pop() ?? command; + return command.replace(/^.*[\\/]/, ''); } +// `.cmd`/`.exe` is how the same program spells itself on Windows, so strip it +// before matching or rendering: `npm.cmd run dev` is `npm run dev`, and the +// per-program cases below (which are keyed on bare names) still fire there. +const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; + function commandTitleTokens(tokens: string[]): string[] { const command = tokens[0]; if (!command) return []; - const basename = commandBasename(command); + const basename = commandBasename(command).replace(WINDOWS_EXECUTABLE_SUFFIX, ''); const rest = tokens.slice(1); if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index 09200ceb..5353b005 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -54,6 +54,13 @@ describe('commandArgv0', () => { }); describe('watched-commands store', () => { + it('drops a key no command line can ever produce', () => { + // Written by the pre-fix tokenizer, which ate the backslashes in + // `C:\tools\claude.exe`. A real key is a basename, so it holds no separator. + applyWatchedCommandsFromHost(['C:toolsclaude.exe', 'claude', '/usr/bin/claude']); + expect(getWatchedCommands()).toEqual(['claude']); + }); + it('adds, reports, and removes rules', () => { expect(getWatchedCommands()).toEqual([]); expect(isCommandWatched('claude')).toBe(false); diff --git a/lib/src/lib/watched-commands.ts b/lib/src/lib/watched-commands.ts index f636e46f..a46f3c71 100644 --- a/lib/src/lib/watched-commands.ts +++ b/lib/src/lib/watched-commands.ts @@ -18,15 +18,26 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); } +/** + * A key is `commandArgv0` output — a basename — so a separator or a `:` in one + * means it can never match any command again. Before the tokenizer learned that + * `\` is a Windows path separator, a full-path invocation stored keys like + * `C:toolsclaude.exe`; dropping them keeps a dead row out of the rule list. + */ +function isKeyableName(name: string): boolean { + return !/[\\/:]/.test(name); +} + function readStored(): string[] { - const raw = loadJson(STORAGE_KEY, [], isStringArray); - // Dedupe and drop blanks defensively: the key is user-visible in devtools and - // a malformed entry would otherwise show up as a blank row in the rule list. - return [...new Set(raw.map((name) => name.trim()).filter(Boolean))].sort(); + return normalize(loadJson(STORAGE_KEY, [], isStringArray)); } +// Dedupe and drop what can never be a rule: the key is user-visible in devtools +// and in the rule list, so a malformed entry would otherwise sit there as a row +// that matches nothing. Applied to both sources, `localStorage` and the host's +// canonical snapshot, since a stale key reaches the mirror either way. function normalize(names: string[]): string[] { - return [...new Set(names.map((name) => name.trim()).filter(Boolean))].sort(); + return [...new Set(names.map((name) => name.trim()).filter(Boolean).filter(isKeyableName))].sort(); } let watched: string[] = readStored(); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 27148c24..195f4022 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/standalone.rationale.md": 375, "docs/specs/terminal-escapes.md": 3950, "docs/specs/terminal-escapes.rationale.md": 350, - "docs/specs/terminal-state.md": 2760, + "docs/specs/terminal-state.md": 2800, "docs/specs/theme.md": 2350, "docs/specs/tiling-engine.md": 5175, "docs/specs/tiling-engine.rationale.md": 900, From 714da04d2b92572042b3d4b71523540ba674964f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 17:54:25 -0700 Subject: [PATCH 05/11] fix(lib): keep terminal-state.ts dependency-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught it: `terminal-state.ts` is bundled into the VS Code extension host and the Tauri sidecar, neither of which resolves the `dor/*` path that `shell-escape.ts` imports. Importing `POSIX_ESCAPABLE` from there dragged the `dor` CLI package into that graph and broke `message-router.test.ts` with `Cannot find package 'dor/commands/shell-quote'`. Give the set its own dependency-free module. It was always a contract between two modules rather than a detail of either — the escaper writes the characters, the tokenizer reads them back — so the shared home also states that plainly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KzMCg7c37NRvdLEhpwmPpz --- lib/src/lib/posix-escape.ts | 19 +++++++++++++++++++ lib/src/lib/shell-escape.ts | 16 +++++----------- lib/src/lib/terminal-state.test.ts | 3 ++- lib/src/lib/terminal-state.ts | 2 +- 4 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 lib/src/lib/posix-escape.ts diff --git a/lib/src/lib/posix-escape.ts b/lib/src/lib/posix-escape.ts new file mode 100644 index 00000000..56dc7147 --- /dev/null +++ b/lib/src/lib/posix-escape.ts @@ -0,0 +1,19 @@ +/** + * The one definition of "characters a POSIX shell backslash-escapes", shared by + * the two halves that have to agree about it: + * + * - `shellEscapePosix` (`shell-escape.ts`) *writes* them, backslash-escaping + * each one so a dropped path pastes as a path rather than as opaque text. + * - `tokenizeCommand` (`terminal-state.ts`) *reads* them back, treating `\` + * before anything else as a literal path separator so a native Windows + * program path survives to the basename split. + * + * They disagreed once, about `~`, and a path Dormouse itself escaped rendered + * with a stray backslash in the pane header. `terminal-state.test.ts` -> + * "command tokenizer dialects" pins both directions character by character. + * + * Its own module because `terminal-state.ts` is bundled into the VS Code + * extension host and the Tauri sidecar, where the `dor/*` path `shell-escape.ts` + * imports does not resolve — this file must stay dependency-free. + */ +export const POSIX_ESCAPABLE = /[ \t!"#$&'()*;<>?[\\\]`{|}~]/; diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index 37f59a67..ef826bb2 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -1,16 +1,10 @@ import { quotePowerShellArg, type ShellCommandKind } from 'dor/commands/shell-quote'; +import { POSIX_ESCAPABLE } from './posix-escape'; -/** - * The shell metacharacters `shellEscapePosix` backslash-escapes, matching macOS - * Terminal's drag-and-drop format: escape each one instead of wrapping in - * quotes. TUIs like `claude` recognize backslash-escaped tokens as filesystem - * paths where a single-quoted whole path gets treated as opaque pasted text. - * - * `tokenizeCommand` in `terminal-state.ts` reads the same set back, so a path - * Dormouse escaped for paste round-trips through Dormouse's own command - * tokenizer; `terminal-state.test.ts` -> "command tokenizer dialects" pins it. - */ -export const POSIX_ESCAPABLE = /[ \t!"#$&'()*;<>?[\\\]`{|}~]/; +// Matches macOS Terminal's drag-and-drop format: backslash-escape each shell +// metacharacter instead of wrapping in quotes. TUIs like `claude` recognize +// backslash-escaped tokens as filesystem paths where a single-quoted whole +// path gets treated as opaque pasted text. const POSIX_UNSAFE = new RegExp(`(${POSIX_ESCAPABLE.source})`, 'g'); const POSIX_NEEDS_QUOTES = /[\n\r]/; diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 1a3d467a..4de6cab4 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { POSIX_ESCAPABLE, shellEscapePosix } from './shell-escape'; +import { POSIX_ESCAPABLE } from './posix-escape'; +import { shellEscapePosix } from './shell-escape'; import { commandArgv0, createTerminalPaneState, diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 80db3285..1d0708c1 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -1,4 +1,4 @@ -import { POSIX_ESCAPABLE } from './shell-escape'; +import { POSIX_ESCAPABLE } from './posix-escape'; export type CwdSource = 'osc7' | 'osc9_9' | 'osc633' | 'osc1337' | 'process' | 'manual'; export type PathKind = 'posix' | 'windows' | 'unknown'; From 4a476939e7166fa37064e404143d605d258aa562 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 18:02:13 -0700 Subject: [PATCH 06/11] fix(terminal-state): one name per program across header, rule row, and tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dormouse-bot on #516: stripping `.cmd`/`.exe` for display while `commandArgv0` keeps it split one program into three names on one screen — header `claude`, WATCHING rule row `claude.exe`, bell tooltip `Alert on all "claude.exe"`. This PR is what split them; before it, both sides mangled identically. Strip the suffix for *matching* the per-program cases only — that is what makes them fire on Windows at all — and render the basename as invoked. The key keeps the suffix, which `commandArgv0`'s doc comment now says, since that is where a reader looks: `foo.bat` and `foo.exe` really can be two files in one directory, and the rule set is the one place conflating them cannot be undone from the UI. Also from that review: - The escape-set comment claimed both costs were display-only. The first is not: when the metacharacter-initial segment is the *last* one, the eaten separator is the one the basename split needed (`C:\tools\$claude.exe` -> `tools$claude.exe`). Verified and corrected. - `posix-escape.ts` claimed the Tauri sidecar as a consumer; the sidecar mirrors constants by hand and imports nothing from lib. Narrowed to what is true. - The spec pointer still named the file `POSIX_ESCAPABLE` moved out of. - `setCommandWatched` now applies the same `isKeyableName` gate `normalize` does, so a key that would vanish on the next reload is never stored. - The round-trip test only proved each listed character was in the set. It now pins the set both ways, so adding a member fails here too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KzMCg7c37NRvdLEhpwmPpz --- docs/specs/terminal-state.md | 2 +- lib/src/lib/posix-escape.ts | 5 ++-- lib/src/lib/terminal-state.test.ts | 37 ++++++++++++++++++------------ lib/src/lib/terminal-state.ts | 34 ++++++++++++++++++--------- lib/src/lib/watched-commands.ts | 5 +++- 5 files changed, 53 insertions(+), 30 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 3faddbfa..8f068166 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -98,7 +98,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources. -Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/shell-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. +Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. ### Keystroke fallback diff --git a/lib/src/lib/posix-escape.ts b/lib/src/lib/posix-escape.ts index 56dc7147..72d78633 100644 --- a/lib/src/lib/posix-escape.ts +++ b/lib/src/lib/posix-escape.ts @@ -13,7 +13,8 @@ * "command tokenizer dialects" pins both directions character by character. * * Its own module because `terminal-state.ts` is bundled into the VS Code - * extension host and the Tauri sidecar, where the `dor/*` path `shell-escape.ts` - * imports does not resolve — this file must stay dependency-free. + * extension host, which resolves the `dor/*` path `shell-escape.ts` imports only + * through a tsconfig mapping its vitest run does not read — so this file must + * stay dependency-free. */ export const POSIX_ESCAPABLE = /[ \t!"#$&'()*;<>?[\\\]`{|}~]/; diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 4de6cab4..45b1478f 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -269,10 +269,12 @@ describe('command title summarizer', () => { expect(summarizeCommandLine('ssh prod-box')).toBe('ssh prod-box'); }); + // Matched on the stripped name, rendered as invoked — the header has to read + // the same name the WATCHING rule row and the bell tooltip show. it('matches its per-program cases through a Windows launcher suffix', () => { - expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim'); - expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo watch -x test'); - expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm'); + expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim.exe'); + expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo.exe watch -x test'); + expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm.cmd'); }); it('keeps pipelines and compound commands recognizable', () => { @@ -287,15 +289,15 @@ describe('command tokenizer dialects', () => { // escapes, so both dialects reduce to the bare program name. it.each([ // Windows: absolute paths, launchers, a quoted path with spaces. - ['C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor tool storybook'], + ['C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], ['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'], - ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm run dev'], - ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude --print'], - ['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm run dev'], + ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], + ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], + ['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], // PowerShell's call operator, the only way that shell runs a quoted path. // Without the leading-`&` skip it reads as a boundary and argv0 is null. - ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm run dev'], - ['& C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor tool storybook'], + ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], + ['& C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], // POSIX escapes keep their meaning. ['/opt/my\\ tools/claude --print', 'claude', 'claude --print'], ['grep \\*.ts src', 'grep', 'grep *.ts src'], @@ -316,14 +318,19 @@ describe('command tokenizer dialects', () => { // `POSIX_ESCAPABLE` is `shellEscapePosix`'s set; the tokenizer unescapes it. // The two halves must name the same characters or a path Dormouse escaped for // a drag-and-drop paste renders with stray backslashes in the pane header. - it.each(Array.from(` \t!"#$&'()*;<>?[]\`{|}~\\`))('round-trips %j out of shellEscapePosix', (char) => { - expect(POSIX_ESCAPABLE.test(char)).toBe(true); - expect(summarizeCommandLine(`cat ${shellEscapePosix(`a${char}b`)}`)).toBe(`cat a${char}b`); + const ESCAPABLE = ` \t!"#$&'()*;<>?[]\`{|}~\\`; + + it('is exactly the set spelled out here, so a change to it lands in this file', () => { + // Both directions, so neither a new nor a dropped member slips through. + expect(Array.from(ESCAPABLE).filter((char) => !POSIX_ESCAPABLE.test(char))).toEqual([]); + const printable = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)); + expect(printable.filter((char) => POSIX_ESCAPABLE.test(char)).join('')).toBe( + Array.from(ESCAPABLE).filter((char) => char !== '\t').sort().join(''), + ); }); - it('holds no path character, which is what keeps Windows separators intact', () => { - const pathChars = Array.from('AZaz09_-.+=,@:^%'); - expect(pathChars.filter((char) => POSIX_ESCAPABLE.test(char))).toEqual([]); + it.each(Array.from(ESCAPABLE))('round-trips %j out of shellEscapePosix', (char) => { + expect(summarizeCommandLine(`cat ${shellEscapePosix(`a${char}b`)}`)).toBe(`cat a${char}b`); }); }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 1d0708c1..3fd0d950 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -383,6 +383,11 @@ export function summarizeCommandLine(raw: string): string { * all yield `claude`; `foo | claude` yields `foo`. Returns null when the line * holds no runnable word. * + * A Windows launcher suffix is part of the key: `C:\tools\claude.exe` yields + * `claude.exe`, not `claude`. `foo.bat` and `foo.exe` can be two files in one + * directory, and the rule set is the one place conflating them cannot be undone + * from the UI — so a rule made on Windows keys on the spelling that was run. + * * This is the key WATCHING rules are stored under — see `docs/specs/alert.md`. */ export function commandArgv0(raw: string): string | null { @@ -796,7 +801,10 @@ function withRequiredHostPrefixes( * separators to split on. Two accepted costs of one dialect-free set: a Windows * segment that starts with a metacharacter (`C:\$Recycle.Bin`) still loses its * separator, and a POSIX escape of an ordinary character (`grep \-v`) keeps a - * backslash bash would drop. Both are display-only — neither reaches argv[0]. + * backslash bash would drop. The second is display-only; so is the first, + * unless the mangled segment is the last one (`C:\tools\$claude.exe` -> + * `tools$claude.exe`), where the separator the basename split needed is the + * one that was eaten. */ function tokenizeCommand(input: string): string[] { const tokens: string[] = []; @@ -890,23 +898,27 @@ function commandBasename(command: string): string { return command.replace(/^.*[\\/]/, ''); } -// `.cmd`/`.exe` is how the same program spells itself on Windows, so strip it -// before matching or rendering: `npm.cmd run dev` is `npm run dev`, and the -// per-program cases below (which are keyed on bare names) still fire there. const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; +// `.cmd`/`.exe` is how the same program spells itself on Windows, so the cases +// below — all keyed on bare names — match against the stripped form, or none of +// them fire on Windows now that argv[0] resolves to `npm.cmd`. They still +// *render* the basename as it was invoked: one program reads as one name across +// the pane header, the WATCHING rule row, and the bell tooltip, and only the +// header goes through here. function commandTitleTokens(tokens: string[]): string[] { const command = tokens[0]; if (!command) return []; - const basename = commandBasename(command).replace(WINDOWS_EXECUTABLE_SUFFIX, ''); + const basename = commandBasename(command); + const matched = basename.replace(WINDOWS_EXECUTABLE_SUFFIX, ''); const rest = tokens.slice(1); - if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; - if (basename === 'pnpm' || basename === 'yarn' || basename === 'bun') return [basename, ...rest.slice(0, 2)]; - if (basename === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; - if (basename === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; - if (basename === 'ssh') return [basename, ...rest.slice(0, 1)]; - if (basename === 'vim' || basename === 'nvim' || basename === 'vi' || basename === 'pytest') return [basename]; + if (matched === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; + if (matched === 'pnpm' || matched === 'yarn' || matched === 'bun') return [basename, ...rest.slice(0, 2)]; + if (matched === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; + if (matched === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; + if (matched === 'ssh') return [basename, ...rest.slice(0, 1)]; + if (matched === 'vim' || matched === 'nvim' || matched === 'vi' || matched === 'pytest') return [basename]; return [basename, ...rest.slice(0, 2)]; } diff --git a/lib/src/lib/watched-commands.ts b/lib/src/lib/watched-commands.ts index a46f3c71..3fc7b212 100644 --- a/lib/src/lib/watched-commands.ts +++ b/lib/src/lib/watched-commands.ts @@ -64,7 +64,10 @@ export function isCommandWatched(name: string | null | undefined): boolean { export function setCommandWatched(name: string, on: boolean): void { const trimmed = name.trim(); - if (!trimmed) return; + // Same gate `normalize` applies on the way in, so a key that would be dropped + // on the next reload is never stored: it would otherwise match for the rest of + // the session and then vanish with nothing on screen to explain it. + if (!trimmed || !isKeyableName(trimmed)) return; if (watched.includes(trimmed) === on) return; watched = on ? [...watched, trimmed].sort() From f8f2ca2de5307a9ecc62c3c0abaa441ddbff0ce2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 18:10:59 -0700 Subject: [PATCH 07/11] test(watched-commands): pin the write-path half of the keyable gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dormouse-bot on #516: `normalize` dropping an unkeyable key was covered, but deleting the `isKeyableName` guard from `setCommandWatched` left the file green — and that guard is the one added because the bug was reachable (`commandArgv0('C:foo.exe --print')` returns `C:foo.exe`, which looks keyable until the next reload). Confirmed the added line is load-bearing: removing the guard turns it red. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KzMCg7c37NRvdLEhpwmPpz --- lib/src/lib/watched-commands.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index 5353b005..cff98dbb 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -59,6 +59,10 @@ describe('watched-commands store', () => { // `C:\tools\claude.exe`. A real key is a basename, so it holds no separator. applyWatchedCommandsFromHost(['C:toolsclaude.exe', 'claude', '/usr/bin/claude']); expect(getWatchedCommands()).toEqual(['claude']); + // Same gate on the write path — a drive-relative invocation is the one + // shape `commandArgv0` can still return with a `:` in it. + setCommandWatched('C:foo.exe', true); + expect(getWatchedCommands()).toEqual(['claude']); }); it('adds, reports, and removes rules', () => { From 06985885c0f289affe0d4e280e78b6c6ed966eea Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 21:08:45 -0700 Subject: [PATCH 08/11] fix(terminal-state): a launcher suffix is not part of a program's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm.cmd`, `C:\tools\claude.exe` and `build.ps1` now reduce to `npm`, `claude` and `build` — for the WATCHING key, the pane header, and the bell tooltip alike. Keeping the suffix left `npm` and `npm.cmd` as two rules for one program, which is the miss this branch exists to close: rules are only ever created from the running command's own argv0, so a rule made from a PATH invocation never matched the same program launched by full path. PATHEXT means the user thinks of it as one program either way. Since `commandTitleTokens` already matched on the stripped form and rendered the unstripped one, folding both onto one `commandProgramName` deletes that split rather than adding a rule. `isGenericProcessTitle` keeps the plain basename — there the suffix is the evidence it tests for. Accepted: `foo.bat` and `foo.exe` in one directory cannot be watched separately. No migration cost — no shipped build ever stored a suffixed key, since POSIX stored bare names and Windows stored the mangled form this branch already drops. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD --- docs/specs/terminal-state.md | 2 +- lib/src/lib/terminal-state.test.ts | 25 ++++++++-------- lib/src/lib/terminal-state.ts | 48 +++++++++++++++++------------- scripts/spec-word-budgets.json | 2 +- 4 files changed, 42 insertions(+), 35 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 8f068166..2164bd0e 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -98,7 +98,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources. -Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. +Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. **A launcher suffix is not part of a program's name**: `npm.cmd` and `C:\tools\claude.exe` are `npm` and `claude` for the header, the WATCHING key, and the bell tooltip alike, so PATHEXT's spellings of one program cannot become two rules. Accepted: `foo.bat` and `foo.exe` in one directory cannot be watched separately. ### Keystroke fallback diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 45b1478f..b4cfd526 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -269,12 +269,13 @@ describe('command title summarizer', () => { expect(summarizeCommandLine('ssh prod-box')).toBe('ssh prod-box'); }); - // Matched on the stripped name, rendered as invoked — the header has to read - // the same name the WATCHING rule row and the bell tooltip show. - it('matches its per-program cases through a Windows launcher suffix', () => { - expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim.exe'); - expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo.exe watch -x test'); - expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm.cmd'); + // One name per program: the launcher suffix is dropped everywhere, so the + // header reads the same name as the WATCHING rule row and the bell tooltip. + it('reads a Windows launcher as the program it launches', () => { + expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim'); + expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo watch -x test'); + expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm'); + expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd run dev')).toBe('npm run dev'); }); it('keeps pipelines and compound commands recognizable', () => { @@ -289,15 +290,15 @@ describe('command tokenizer dialects', () => { // escapes, so both dialects reduce to the bare program name. it.each([ // Windows: absolute paths, launchers, a quoted path with spaces. - ['C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + ['C:\\tools\\dor.cmd tool storybook', 'dor', 'dor tool storybook'], ['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'], - ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], - ['\\\\build\\share\\tools\\claude.exe --print', 'claude.exe', 'claude.exe --print'], - ['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], + ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'], + ['\\\\build\\share\\tools\\claude.exe --print', 'claude', 'claude --print'], + ['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'], // PowerShell's call operator, the only way that shell runs a quoted path. // Without the leading-`&` skip it reads as a boundary and argv0 is null. - ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm.cmd', 'npm.cmd run dev'], - ['& C:\\tools\\dor.cmd tool storybook', 'dor.cmd', 'dor.cmd tool storybook'], + ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'], + ['& C:\\tools\\dor.cmd tool storybook', 'dor', 'dor tool storybook'], // POSIX escapes keep their meaning. ['/opt/my\\ tools/claude --print', 'claude', 'claude --print'], ['grep \\*.ts src', 'grep', 'grep *.ts src'], diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 3fd0d950..ae0c8d1d 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -383,10 +383,12 @@ export function summarizeCommandLine(raw: string): string { * all yield `claude`; `foo | claude` yields `foo`. Returns null when the line * holds no runnable word. * - * A Windows launcher suffix is part of the key: `C:\tools\claude.exe` yields - * `claude.exe`, not `claude`. `foo.bat` and `foo.exe` can be two files in one - * directory, and the rule set is the one place conflating them cannot be undone - * from the UI — so a rule made on Windows keys on the spelling that was run. + * A Windows launcher suffix is not part of the name: `C:\tools\claude.exe`, + * `npm.cmd` and `build.ps1` yield `claude`, `npm` and `build`. `.exe` / `.cmd` + * is how one program spells itself when PATHEXT resolves it, so keeping the + * suffix would leave `npm` and `npm.cmd` as two rules for one program — the + * miss this whole path exists to close. Accepted: `foo.bat` and `foo.exe` in + * one directory cannot be watched separately. * * This is the key WATCHING rules are stored under — see `docs/specs/alert.md`. */ @@ -394,7 +396,7 @@ export function commandArgv0(raw: string): string | null { const commandTokens = takePrimaryCommandTokens(tokenizeCommand(raw.trim())); const command = commandTokens[0]; if (!command) return null; - return commandBasename(command) || null; + return commandProgramName(command) || null; } export interface ResolvedCommandStart { @@ -797,7 +799,7 @@ function withRequiredHostPrefixes( * A `\` escapes exactly the `POSIX_ESCAPABLE` set (`foo\ bar` is one token, * `\*.ts` passes a literal glob, and a path Dormouse escaped for paste reads * back as itself); before anything else it is a literal, so a native Windows - * program path survives tokenizing intact and `commandBasename` still has + * program path survives tokenizing intact and `commandProgramName` still has * separators to split on. Two accepted costs of one dialect-free set: a Windows * segment that starts with a metacharacter (`C:\$Recycle.Bin`) still loses its * separator, and a POSIX escape of an ordinary character (`grep \-v`) keeps a @@ -893,32 +895,34 @@ function isEnvAssignment(token: string | undefined): boolean { return !!token && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token); } -/** argv[0] reduced to a bare program name, in either path dialect. */ +/** A path reduced to its last segment, in either dialect. */ function commandBasename(command: string): string { return command.replace(/^.*[\\/]/, ''); } const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; -// `.cmd`/`.exe` is how the same program spells itself on Windows, so the cases -// below — all keyed on bare names — match against the stripped form, or none of -// them fire on Windows now that argv[0] resolves to `npm.cmd`. They still -// *render* the basename as it was invoked: one program reads as one name across -// the pane header, the WATCHING rule row, and the bell tooltip, and only the -// header goes through here. +/** + * argv[0] reduced to the one name a program answers to: no path, no launcher + * suffix. The single answer to "which program is this", so the header, the + * WATCHING rule row and the bell tooltip cannot disagree about it. + */ +function commandProgramName(command: string): string { + return commandBasename(command).replace(WINDOWS_EXECUTABLE_SUFFIX, ''); +} + function commandTitleTokens(tokens: string[]): string[] { const command = tokens[0]; if (!command) return []; - const basename = commandBasename(command); - const matched = basename.replace(WINDOWS_EXECUTABLE_SUFFIX, ''); + const basename = commandProgramName(command); const rest = tokens.slice(1); - if (matched === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; - if (matched === 'pnpm' || matched === 'yarn' || matched === 'bun') return [basename, ...rest.slice(0, 2)]; - if (matched === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; - if (matched === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; - if (matched === 'ssh') return [basename, ...rest.slice(0, 1)]; - if (matched === 'vim' || matched === 'nvim' || matched === 'vi' || matched === 'pytest') return [basename]; + if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; + if (basename === 'pnpm' || basename === 'yarn' || basename === 'bun') return [basename, ...rest.slice(0, 2)]; + if (basename === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; + if (basename === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; + if (basename === 'ssh') return [basename, ...rest.slice(0, 1)]; + if (basename === 'vim' || basename === 'nvim' || basename === 'vi' || basename === 'pytest') return [basename]; return [basename, ...rest.slice(0, 2)]; } @@ -991,6 +995,8 @@ const GENERIC_PROCESS_TITLE_NAMES = new Set([ function isGenericProcessTitle(title: string): boolean { const trimmed = title.trim(); if (!trimmed) return false; + // Basename, not program name: the suffix is the evidence this test is looking + // for, so stripping it would leave every `.exe` title indistinguishable. const basename = commandBasename(trimmed); if (/\s/.test(basename)) return false; // carries arguments/description → meaningful if (WINDOWS_EXECUTABLE_SUFFIX.test(basename)) return true; // bare executable path diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 195f4022..51c705ce 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/standalone.rationale.md": 375, "docs/specs/terminal-escapes.md": 3950, "docs/specs/terminal-escapes.rationale.md": 350, - "docs/specs/terminal-state.md": 2800, + "docs/specs/terminal-state.md": 2875, "docs/specs/theme.md": 2350, "docs/specs/tiling-engine.md": 5175, "docs/specs/tiling-engine.rationale.md": 900, From 72f01c1e582ca2a2381985e2ff3ae82fef50734d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 21:17:59 -0700 Subject: [PATCH 09/11] fix(watched-commands): drop the keys the suffix strip stranded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isKeyableName` only caught a separator or a `:`, which the old mangling left behind for a drive-absolute path alone. A relative invocation had no separator to eat (`tools\dor.cmd` -> `toolsdor.cmd`, `.\build.ps1` -> `.build.ps1`), and a bare launcher stored cleanly as `npm.cmd` — all three match nothing now that `commandProgramName` strips the suffix, and all three sat in the rule list looking real. One tell covers the class: `commandArgv0` can no longer return a name ending in a launcher suffix, so a stored key that does is dead. Residual, now stated at the gate: a mangled relative path with no suffix (`bin\claude` -> `binclaude`) reads exactly like a program named that and survives for the user to delete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD --- lib/src/lib/terminal-state.ts | 4 +++- lib/src/lib/watched-commands.test.ts | 6 ++++++ lib/src/lib/watched-commands.ts | 18 +++++++++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index ae0c8d1d..67b6d363 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -900,7 +900,9 @@ function commandBasename(command: string): string { return command.replace(/^.*[\\/]/, ''); } -const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; +/** PATHEXT's spellings of one program. Exported for `watched-commands.ts`, + * which drops a stored key ending in one: `commandArgv0` cannot produce one. */ +export const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; /** * argv[0] reduced to the one name a program answers to: no path, no launcher diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index cff98dbb..a73fe60f 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -63,6 +63,12 @@ describe('watched-commands store', () => { // shape `commandArgv0` can still return with a `:` in it. setCommandWatched('C:foo.exe', true); expect(getWatchedCommands()).toEqual(['claude']); + // A launcher suffix is the other tell: a relative invocation had no + // separator to eat (`tools\\dor.cmd` -> `toolsdor.cmd`), and a bare + // `npm.cmd` stored cleanly — but `commandProgramName` strips the suffix, so + // neither can match again. + applyWatchedCommandsFromHost(['npm.cmd', 'toolsdor.cmd', '.build.ps1', 'claude']); + expect(getWatchedCommands()).toEqual(['claude']); }); it('adds, reports, and removes rules', () => { diff --git a/lib/src/lib/watched-commands.ts b/lib/src/lib/watched-commands.ts index 3fc7b212..bc430e0e 100644 --- a/lib/src/lib/watched-commands.ts +++ b/lib/src/lib/watched-commands.ts @@ -1,5 +1,6 @@ import { loadJson, saveJson } from './local-json-store'; import { getPlatform } from './platform'; +import { WINDOWS_EXECUTABLE_SUFFIX } from './terminal-state'; /** * The WATCHING rule set: the bare program names (`commandArgv0` output) whose @@ -19,13 +20,20 @@ function isStringArray(value: unknown): value is string[] { } /** - * A key is `commandArgv0` output — a basename — so a separator or a `:` in one - * means it can never match any command again. Before the tokenizer learned that - * `\` is a Windows path separator, a full-path invocation stored keys like - * `C:toolsclaude.exe`; dropping them keeps a dead row out of the rule list. + * Whether a stored key is one `commandArgv0` can still produce. It is a bare + * program name, so it holds no separator or `:`, and it never ends in a + * launcher suffix — `commandProgramName` strips those. Keys written before this + * module's fixes fail one test or the other: a full path mangled to + * `C:toolsclaude.exe`, a relative one to `toolsdor.cmd`, and a bare launcher + * stored cleanly as `npm.cmd`. Each can only sit in the rule list as a row that + * matches nothing, so it is dropped rather than shown. + * + * Residual: a mangled *relative* path with no suffix (`bin\claude` -> + * `binclaude`) is indistinguishable from a program actually named that, and + * survives. The user deletes it from the rule list. */ function isKeyableName(name: string): boolean { - return !/[\\/:]/.test(name); + return !/[\\/:]/.test(name) && !WINDOWS_EXECUTABLE_SUFFIX.test(name); } function readStored(): string[] { From 2ca3b4471f7c88b0f76fc861c66316f3b879b8d6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 21:26:24 -0700 Subject: [PATCH 10/11] docs(alert): the WATCHING key is a basename minus any launcher suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alert.md owns the key spec and still said "reduce argv[0] to its basename" with POSIX-only examples, so a reader computing the key for `npm.cmd` from the line that defines it got `npm.cmd` — the exact shape `isKeyableName` now deletes. A pointer, not a restatement: terminal-state.md holds the rule and its examples. `commandTitleTokens`'s local is `program`, not `basename` — it holds `commandProgramName`, three lines under a `commandBasename` that means the other thing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD --- docs/specs/alert.md | 2 +- lib/src/lib/terminal-state.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 0faa58b4..839b9c88 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -147,7 +147,7 @@ In VS Code the `AlertManager` lives in the extension host while `dor` control re Rules: -- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename. `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports. +- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename, minus any launcher suffix (`docs/specs/terminal-state.md`). `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports. - Every command boundary — `commandStart`, `commandFinish`, `promptStart`, `promptEnd`, and PTY exit — resets the detector, so one command's output history can never leak into the next one's reading. Editing the rule set re-derives WATCHING across every live Session immediately, and because the detector kept running underneath, enabling a rule mid-command shows what that command is doing *right now* rather than a fresh `NOTHING_TO_SHOW`. - A WATCHING ring outlives the command that raised it. Watching switches off the moment the watched command exits, which is usually the same moment the ring was raised, so the ring and its originating command key are held in the Session entry (`watchingRingingCommand`). - Removing a rule is the one thing that *does* silence a WATCHING ring: it is the user saying "stop alerting on this". The latched originating key makes this work after the command has exited and watching is already off. A command merely ending never clears the ring. diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 67b6d363..f8c14dc8 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -916,16 +916,16 @@ function commandProgramName(command: string): string { function commandTitleTokens(tokens: string[]): string[] { const command = tokens[0]; if (!command) return []; - const basename = commandProgramName(command); + const program = commandProgramName(command); const rest = tokens.slice(1); - if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; - if (basename === 'pnpm' || basename === 'yarn' || basename === 'bun') return [basename, ...rest.slice(0, 2)]; - if (basename === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; - if (basename === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; - if (basename === 'ssh') return [basename, ...rest.slice(0, 1)]; - if (basename === 'vim' || basename === 'nvim' || basename === 'vi' || basename === 'pytest') return [basename]; - return [basename, ...rest.slice(0, 2)]; + if (program === 'npm' && rest[0] === 'run') return [program, ...rest.slice(0, 2)]; + if (program === 'pnpm' || program === 'yarn' || program === 'bun') return [program, ...rest.slice(0, 2)]; + if (program === 'docker' && rest[0] === 'compose') return [program, ...rest.slice(0, 2)]; + if (program === 'cargo' && rest[0] === 'watch') return [program, ...rest.slice(0, 3)]; + if (program === 'ssh') return [program, ...rest.slice(0, 1)]; + if (program === 'vim' || program === 'nvim' || program === 'vi' || program === 'pytest') return [program]; + return [program, ...rest.slice(0, 2)]; } function truncateCommandTitle(title: string): string { From af7a7b290f1fccf8de7c0fa42879dbd9b5ea1588 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 1 Sep 2026 22:40:25 -0700 Subject: [PATCH 11/11] fix: narrow drive filter and admit argv0 escape cost --- lib/src/lib/terminal-state.test.ts | 4 ++++ lib/src/lib/terminal-state.ts | 8 ++++---- lib/src/lib/watched-commands.test.ts | 22 +++++++++++++++++----- lib/src/lib/watched-commands.ts | 18 +++++++++++------- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index b4cfd526..0f12854e 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -316,6 +316,10 @@ describe('command tokenizer dialects', () => { expect(commandArgv0('"C:\\Program Files\\Git\\bin\\bash" scripts\\bootstrap.cmd')).toBe('bash'); }); + it('pins the ordinary POSIX argv[0] escape cost of dialect-free tokenizing', () => { + expect(commandArgv0('foo\\-bar')).toBe('-bar'); + }); + // `POSIX_ESCAPABLE` is `shellEscapePosix`'s set; the tokenizer unescapes it. // The two halves must name the same characters or a path Dormouse escaped for // a drag-and-drop paste renders with stray backslashes in the pane header. diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index f8c14dc8..b6be4766 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -803,10 +803,10 @@ function withRequiredHostPrefixes( * separators to split on. Two accepted costs of one dialect-free set: a Windows * segment that starts with a metacharacter (`C:\$Recycle.Bin`) still loses its * separator, and a POSIX escape of an ordinary character (`grep \-v`) keeps a - * backslash bash would drop. The second is display-only; so is the first, - * unless the mangled segment is the last one (`C:\tools\$claude.exe` -> - * `tools$claude.exe`), where the separator the basename split needed is the - * one that was eaten. + * backslash bash would drop. Outside argv[0] both costs are display-only. Inside + * it, the retained POSIX backslash becomes a basename separator (`foo\-bar` -> + * `-bar`), while an eaten Windows separator leaves `C:\tools\$claude.exe` + * keyed as `tools$claude.exe`. */ function tokenizeCommand(input: string): string[] { const tokens: string[] = []; diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index a73fe60f..ec44a8ac 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -57,18 +57,30 @@ describe('watched-commands store', () => { it('drops a key no command line can ever produce', () => { // Written by the pre-fix tokenizer, which ate the backslashes in // `C:\tools\claude.exe`. A real key is a basename, so it holds no separator. - applyWatchedCommandsFromHost(['C:toolsclaude.exe', 'claude', '/usr/bin/claude']); - expect(getWatchedCommands()).toEqual(['claude']); + // A colon outside a leading drive prefix is legal in a POSIX basename. + applyWatchedCommandsFromHost([ + 'C:toolsclaude.exe', + 'claude', + 'foo:bar', + '/usr/bin/claude', + ]); + expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']); // Same gate on the write path — a drive-relative invocation is the one // shape `commandArgv0` can still return with a `:` in it. setCommandWatched('C:foo.exe', true); - expect(getWatchedCommands()).toEqual(['claude']); + expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']); // A launcher suffix is the other tell: a relative invocation had no // separator to eat (`tools\\dor.cmd` -> `toolsdor.cmd`), and a bare // `npm.cmd` stored cleanly — but `commandProgramName` strips the suffix, so // neither can match again. - applyWatchedCommandsFromHost(['npm.cmd', 'toolsdor.cmd', '.build.ps1', 'claude']); - expect(getWatchedCommands()).toEqual(['claude']); + applyWatchedCommandsFromHost([ + 'npm.cmd', + 'toolsdor.cmd', + '.build.ps1', + 'claude', + 'foo:bar', + ]); + expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']); }); it('adds, reports, and removes rules', () => { diff --git a/lib/src/lib/watched-commands.ts b/lib/src/lib/watched-commands.ts index bc430e0e..2681f64d 100644 --- a/lib/src/lib/watched-commands.ts +++ b/lib/src/lib/watched-commands.ts @@ -21,19 +21,23 @@ function isStringArray(value: unknown): value is string[] { /** * Whether a stored key is one `commandArgv0` can still produce. It is a bare - * program name, so it holds no separator or `:`, and it never ends in a - * launcher suffix — `commandProgramName` strips those. Keys written before this - * module's fixes fail one test or the other: a full path mangled to - * `C:toolsclaude.exe`, a relative one to `toolsdor.cmd`, and a bare launcher - * stored cleanly as `npm.cmd`. Each can only sit in the rule list as a row that - * matches nothing, so it is dropped rather than shown. + * program name, so it holds no separator or legacy Windows drive prefix, and it + * never ends in a launcher suffix — `commandProgramName` strips those. Keys + * written before this module's fixes fail one test or the other: a full path + * mangled to `C:toolsclaude.exe`, a relative one to `toolsdor.cmd`, and a bare + * launcher stored cleanly as `npm.cmd`. Each can only sit in the rule list as + * a row that matches nothing, so it is dropped rather than shown. * * Residual: a mangled *relative* path with no suffix (`bin\claude` -> * `binclaude`) is indistinguishable from a program actually named that, and * survives. The user deletes it from the rule list. */ function isKeyableName(name: string): boolean { - return !/[\\/:]/.test(name) && !WINDOWS_EXECUTABLE_SUFFIX.test(name); + return ( + !/[\\/]/.test(name) && + !/^[A-Za-z]:/.test(name) && + !WINDOWS_EXECUTABLE_SUFFIX.test(name) + ); } function readStored(): string[] {