Skip to content

fix(terminal): use a cwd folder with $ or \ in its name as written - #569

Merged
ThomasK33 merged 7 commits into
mainfrom
fix/terminal-cwd-as-written
Nov 12, 2026
Merged

ThomasK33 merged 7 commits into
mainfrom
fix/terminal-cwd-as-written

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Why

A Claude terminal did not start, or started in another folder, when the folder name held $ or \. build_config in lua/claudecode/terminal.lua ran a static cwd and every cwd_provider result through vim.fn.expand. expand reads [, {, *, ?, $ and \ in a name as patterns or variables. So a folder named $HOME-like became <home folder>-like, a folder named back\slash became backslash, and [x] {y} and it's came back with a trailing slash. When the changed path did not exist, the terminal job did not start (a real process run at base got Vim:E475: Invalid argument: expected valid directory: the native provider reported "Failed to open native terminal." and the snacks provider "Failed to open Claude terminal using Snacks."). When it existed, the terminal started in that other folder. #561 fixed cwd.git_root for such folders, so the root README's provider example (git_root(...)) now returns the repository root there. This change makes the terminal keep that result. An independent verifier of #561 (finding P1) found the terminal problem.

What changed

  1. build_config takes a cwd that names an existing folder as written. It picks the provider result first, then the static cwd, as before. It then checks vim.fn.isdirectory on that text. A folder that exists is the cwd. Any other text still goes through vim.fn.expand, so ~/projects/my-app and $HOME/my-app keep working. Since fix(cwd): find a repository whose folder name has a space, a comma or other path characters #561, normalize_dir in cwd.lua follows the same rule for cwd.git_root and cwd.resolve. Text that starts with ~ or $ always goes through expand first, because isdirectory does not expand and a stray folder named ~ in Neovim's current folder must not take the place of the home folder. A verifier found that hole in my first version.
  2. Docs. The root README ("Working Directory Control") and the "Terminal cwd precedence" bullet in lua/claudecode/README.md state the rule.
  3. A real-Neovim spec, tests/integration/terminal_cwd_spec.lua, with 31 cases. It stubs vim.fn.termopen, runs :ClaudeCodeOpen and reads the cwd that the terminal job got.

Scope

  • Only build_config changes in code. The order stays: provider, then static cwd, then the git root with git_repo_cwd. A provider that returns an empty string or raises an error still falls through to the static cwd (the spec checks both).
  • cwd.lua does not change.
  • Text that starts with ~ or $, and text that names no folder, expands as before, so a provider result ~/sub and a cwd of $VAR/sub work (the spec checks both for each setting). With a stray folder of that name in Neovim's current folder, it checks ~/sub for each setting, $HOME as a static cwd, and $HOME/sub as a provider result.

Blast Radius

  • User-visible: a folder with $, \, [ or { in its name is now the terminal's cwd as written (a folder with * or ? already worked on main when the pattern matched no other folder; a* with a sibling aXY now works too). A plain path gives the same result as before.
  • Text that starts with ~ or $ always goes through expand first, so cwd = "~" or "$HOME" reaches the home folder even when Neovim's current folder holds a folder with that literal name, as on main. The cost is a relative folder name that starts with $ and is meant literally: it is expanded, as on main. An absolute path never starts with ~ or $, so every folder name this PR fixes keeps working.
  • The terminal passes this cwd to the native provider (termopen) and the snacks provider (cwd in the Snacks window options), so both get the corrected path. The external and none providers do not read it.
  • covered.lock: terminal.lua is covered by the diff lifecycle model and the terminal lifecycle model. check.sh printed STALE for the file hash of terminal.lua only, in both, with no changed block or call site. The change only decides which cwd string the terminal gets. It cannot reach the terminal's start, show, hide, close, exit handling, adoption or provider choice, and it cannot reach diff state, an openDiff answer, a disconnect or a diff command. Both models pass (diff lifecycle main: 89205 distinct states and live-DisconnectedCallsAnswered: 5269, every fact TRUE. Terminal lifecycle main: 263060 distinct states, every fact TRUE). check.sh --record wrote both locks.
  • fix(cwd): find a repository whose folder name has a space, a comma or other path characters #561 landed first. This branch merges origin/main after it. The only conflict was in lua/claudecode/README.md. fix(cwd): find a repository whose folder name has a space, a comma or other path characters #561 added a bullet that said: "The terminal does not use cwd.resolve: terminal.lua:build_config passes a static cwd and a cwd_provider result through vim.fn.expand itself, so there a folder name with $ or \ still changes." I reworded that clause. It now says: "The terminal does not call cwd.resolve, but terminal.lua:build_config applies the same as-written rule to a static cwd and a cwd_provider result, so an existing folder whose text does not start with ~ or $ keeps $ or \ there too. Unlike normalize_dir, it passes expanded text that names no folder on to the terminal instead of dropping it."

Verification

main this branch
tests/integration/terminal_cwd_spec.lua (31 cases, real Neovim 0.8.0 and 0.12.3) 23 successes, 8 failures 31 successes, 0 failures
the four cases "expands a leading ~ / $ ... even when Neovim's cwd holds a folder named ~ / $HOME", with terminal.lua from f643b755 (before the guard) 27 successes, 4 failures 31 successes, 0 failures

The 8 failures on main are the folders with a single quote, brackets and braces, a backslash and a dollar sign, each for a static cwd and for a cwd_provider result. The 23 successes on main include a plain folder, a space, a comma, a star and a question mark, a percent sign and a hash, a leading tilde, the ~ and $VAR expansions, the four stray-folder cases, and the precedence cases.

Gates on 8cf07210, whose parent 7ec5fbb8 merges origin/main ab6417f (with #561) into the branch (private XDG folders). The last commit changes one sentence of lua/claudecode/README.md and no code, so mise run all, the integration runners and the full model checks ran on its parent, 7ec5fbb8. For the last commit I re-ran format-check and check.sh --hash-only on all four skills (both pass):

  • mise run all: native exit 0. luacheck: 0 warnings / 0 errors in 187 files. busted: 1818 successes / 0 failures / 0 errors / 30 pending.
  • mise run format-check: native exit 0 (429 files, 0 changed).
  • scripts/run_integration_tests_individually.sh on Neovim 0.12.3: 603 successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 599 successes, 0 failed, 0 errors, native exit 0.
  • Full check.sh of verify-diff-lifecycle and verify-terminal-lifecycle, (both models pass with exit 0 and every fact TRUE: diff lifecycle 21, terminal lifecycle 34. No record was needed after the merge), then all four .agents/skills/verify-*/check.sh --hash-only: every one matches its covered.lock.

Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

…written

build_config ran a static cwd and a cwd_provider result through vim.fn.expand.
expand reads `[`, `{`, `*`, `?`, `$` and `\` in a name as patterns or
variables, so a folder named `$HOME-like` or `back\slash` became another path,
and `[x] {y}` and `it's` came back with a trailing slash.

A cwd that names an existing folder is now used as written. Any other text
still goes through expand, so `~` and `$VAR` keep working.

The change cannot reach the terminal's start, show, hide, close, exit handling,
adoption or provider choice, because it changes only which cwd string is
computed. Both lifecycle models pass (every fact TRUE), so both covered.lock
files are recorded for the new terminal.lua.
… a folder

isdirectory does not expand, so with `cwd = "~"` or `"$HOME"` (or a provider
that returns such text) and a stray folder of that literal name in Neovim's
current folder (for example one that an unexpanded `mkdir "~/x"` made), the
terminal started Claude in the stray folder. Main started it in the home folder.

Text that starts with `~` or `$` now always goes through expand first, as in a
shell and in `:cd`. Three spec cases are red without the guard: a stray `~`
folder, a stray `$HOME` folder, and a provider result that starts with `~`.
Both READMEs state the rule, and both covered.lock files are recorded for the
new terminal.lua (both models pass, every fact TRUE).
# Conflicts:
#	lua/claudecode/README.md
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T16:53:40.957135Z 8cf0721 Manual request
🔒 Security Review ✅ Completed 2026-11-12T16:55:23.552878Z 8cf0721 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 8cf0721013

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8cf0721013

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 5c7a4db Nov 12, 2026
7 checks passed
@ThomasK33
ThomasK33 deleted the fix/terminal-cwd-as-written branch November 12, 2026 17:49
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…it (coder#544)

Two ways an autocmd could interrupt a native start still broke it after
coder#527, and a third broke a hide later. This PR fixes all three. I found
the first two while verifying coder#525 and coder#527 (the verifiers of both PRs
reproduced them on `main`).

This is round 7. The maintainer allowed one review round past the cap of
six for this push (the
[waiver](coder#544 (comment))).
Round 3 of this PR ran the `vsplit` under `:noautocmd`. Both verifiers
of that head (`a7b86897`) found the same blocker: `:noautocmd` skips
`WinLeave` of the window you leave, so a `WinLeave` autosave (`autocmd
WinLeave * silent! update`) no longer saved the file before Claude
started (7 of 8 cases, on both Neovim builds). Round 4 dropped
`:noautocmd`, so no autocmd is skipped any more. Round 5 (`fdb2b082`)
got two blockers from the verifier (the skill's runner could not run the
new replay, and a close right after `termopen` ended Neovim in a few
runs in 100 under load) and three P2 findings from Codex. Round 6
(`a171a86f`) fixed both blockers and two of the three P2 findings, and
Codex found no major issue.

The verifier of `a171a86f` then returned ISSUES, all about the wait that
round 6 added to `close_terminal` (it asked `nvim_get_proc` for the name
of the child, and `nvim_get_proc` runs `ps` on Unix): (1) where `ps -p`
does not work, every native close raised after it had cleared its state,
so Claude kept running untracked and the next open started a second one.
(2) A close from a `TermClose` autocmd raised `Invalid 'pid'` on a job
that had exited, and no test covered the guard. (3) Every close cost 100
to 190 ms (1.1 to 1.3 s for a child named `nvim`). (4) No test covered
the limit of the wait. (5) The docs gave a failure rate that the
verifier could not reproduce, and none of them named the `ps` call or
its cost. This push fixes all five (see "The fix" and "Tests").

## What breaks

1. **Two Claudes.** The `vsplit` of a start fires `WinLeave`, `WinNew`
and `WinEnter`, and creating the buffer fires `BufNew` and `BufAdd`, all
before the plugin has marked a buffer for Claude. An `open`,
`simple_toggle` or `focus_toggle` that such an autocmd calls finds
nothing to adopt and starts a second Claude. Nothing tracks it, and a
close returns with one still running. (A verifier found the
`BufNew`/`BufAdd` form: 60 of 64 cases on `main`.)
2. **A close in the middle of `termopen`.** A `close()` from a
`BufEnter` or `TermOpen` autocmd cancels the start with a "Failed to
open native terminal" error, and a `close()` from a `TermOpen` autocmd
waits for the job inside `termopen`. On the 0.8-era build, that wait
aborts Neovim (`corrupted double-linked list`, segfault, exit 139).
3. **A hide that ends Claude.** An autocmd of yours can change
`bufhidden` after the start (a `TermEnter` autocmd that sets `wipe`).
The next hide closed the window, which wiped the buffer and ended
Claude. (The verifier of coder#527 found this; coder#527 sets `hide` again after
`termopen`, but not at the hide.)
4. **A hide that raises.** The write of `bufhidden` in a hide fires
`OptionSet`, and an autocmd of that event can end Claude (`close()`),
hide it or show it again. The hide then closed `winid`, which was `nil`
(`Invalid 'win'`) or the window that a nested toggle had shown. This
came with the fix of 3 (Codex found it in round 4). Codex then found a
second form in round 5: an autocmd shows a file of yours in Claude's
window, and the hide closed your window.
5. **A close right after a start ends or hangs Neovim.** A job that was
just started is still a fork of Neovim until its child runs `exec`. A
`jobstop` on it ended Neovim with `SIGHUP` (exit 1), or left it hanging
in the buffer delete. A close that an autocmd of a start asked for (item
2) stops the job right after `termopen`, and so does `open()` followed
by `close()` in one call, which also does it on `main`. On my host it
happened in 6 of 1260 runs at a load average of 50 to 100 (7 orders: the
three autocmd closes and `open()` then `close()`, on both builds). The
verifier saw 62 of 1120 at a median load of 105, and 0 of 1120 with the
wait.

**Not in this PR** (each is a follow-up after this PR, with its own
tracking issue):
- An autocmd of `WinNew`, `WinEnter` or `WinLeave` that closes the new
split, or moves to another window, still makes the start take the window
you are in (as on `main`). The fix needs a proof of which window the
plugin made, and it goes with the change that makes the exit close only
a window that the plugin made.
- A plugin reload from inside an autocmd of a start (Codex round 5): the
new module copy has `starting = false` and no marked buffer yet, so it
can start a second Claude. `main` has the same gap. The fix needs a
start flag that survives a reload, the pending close request, and a
model with separate module generations.
- Two notes of the verifier about an `OptionSet` hide (on the 0.8-era
build, and that `OptionSet` runs with Claude's window current).

## The fix (`lua/claudecode/terminal/native.lua`)

- **`open_terminal` returns while a start runs** (`if starting then
return end`). The start makes the one Claude, so an entry that one of
its autocmds calls does nothing. The `vsplit` keeps every autocmd. This
fixes 1.
- **`close_terminal` waits for the exec, only for a job that was just
started.** `job_started_at` records when `termopen` returned. A close of
a job that this module started less than 10 s ago waits up to 1 s (a
terminal adopted after a plugin reload has no start time and does not
wait) (checked every 5 ms) until the child of the job has the name of
its command, not the name of Neovim, and then calls `jobstop`. An older
job calls `jobstop` at once, with no lookup. A job that already exited
has no pid (`jobpid` raises), so the wait does not run. The names come
from `/proc/<pid>/comm` (a read of a few bytes) where `/proc` exists.
Elsewhere (macOS, the BSDs) `nvim_get_proc` gives them, which runs `ps`,
under `pcall`: when no name can be read (`ps` is missing, or it fails),
the close goes on to `jobstop` as before, with no wait and no raise.
This fixes 5, and the five findings of the verifier (the `ps` raise, the
guard, the cost, the limit).
- A `close()` during a start sets `close_after_start`, and the start
runs it when it returns. It no longer waits inside `termopen`. If the
start raises after the job started, the close still runs first, and then
the error of the start propagates. This fixes 2.
- `open_terminal` is now a small wrapper that calls `start_terminal`
under `pcall`, so an error of the start cannot leave `starting` set
(which would defer every later close). The old body of `open_terminal`
is `start_terminal`.
- `hide_terminal` sets `bufhidden=hide` before it closes the window, and
only when it is not `hide` already, because a write of the same value
fires `OptionSet`. This fixes 3. After the write it looks at the window
again, and it returns when `winid` changed, when its window is gone,
when it shows another buffer than Claude's, or when it became the last
window. This fixes 4.

**Behavior effects:** no autocmd is skipped. An entry that a start's
early autocmd calls does nothing, where it started a second Claude. A
`close()` that an autocmd of the start calls returns before Claude
stops, and Claude stops when the start returns. `terminal/README.md`
says so.

## Tests

29 new tests in `tests/unit/native_terminal_toggle_spec.lua` (101
before, 130 now). The mock gets a hook that runs an autocmd inside the
`vsplit`, one that runs it inside `nvim_create_buf` (`BufNew`,
`BufAdd`), one that runs an `OptionSet` autocmd inside a write of
`bufhidden`, a strict `nvim_get_proc` (it raises for a pid that is not a
number, like `ps -p` with a bad pid, and counts its lookups), `getpid`,
`vim.loop.hrtime`, and a stub of `/proc/<pid>/comm` that a test can
switch off (`io.open`, set with `rawset` as the other specs do). The
shared mock `tests/mocks/vim.lua` gets `loop.hrtime`.

- An entry (`open`, `simple_toggle`, `focus_toggle`) that a
`WinLeave`/`WinNew`/`WinEnter` autocmd of the split or a
`BufNew`/`BufAdd` autocmd of the new buffer calls: one Claude runs,
tracked, in one window, with no notice (6 tests).
- A `vsplit` that raises does not leave `starting` set (1). A start that
raises after the job started leaves a Claude that a close still stops
(1). A close that an autocmd asked for during such a start still runs
before the error propagates (1), and the error of the start is the one
that propagates when the close raises too (1).
- `close()` from a `BufEnter` or `TermOpen` autocmd: Claude stops after
the start, with no notice, no window and no buffer left (2). The next
open starts Claude again after a close that waited for the start (1).
- A close of a job that was just started (7 tests): `jobstop` comes only
after the exec, with the name from `/proc` (1) and from `nvim_get_proc`
(1). The wait ends at its limit when the child never runs `exec` (1). A
close where no name can be read (no `/proc`, `ps` missing) goes on to
`jobstop` with no wait and no raise (1). A close of a job that exited
(`jobpid` raises) makes no lookup (1). A close of a job older than 10 s
makes no lookup (1). A close that an autocmd asked for during a good
start raises the error of that close (1).
- A hide after `bufhidden` was changed to `wipe`: Claude keeps running,
and a toggle shows it again (1). A hide that finds `bufhidden` already
`hide` writes nothing (1).
- An `OptionSet` autocmd of `bufhidden` that runs inside the hide: it
calls `close()` (Claude stops, nothing raises), a nested `simple_toggle`
or `focus_toggle` (Claude stays hidden), hides and shows Claude again
(the new window stays), closes Claude's window itself, closes every
other window (the window stays), or swaps a file of yours into Claude's
window (the window stays and shows your file). That is 7 tests.

On `main` (`5c7a4db9`), 20 of the 29 fail (19 failures and 1 error). The
other 9 guard state that the fix introduces. I removed each guard in
turn (the `starting` return, setting `starting`, resetting it, the
deferral, running the deferred close, resetting `close_after_start`, the
`pcall`, the hide-time `bufhidden`, writing it only when it differs, and
which error propagates (also when the start went well), the four clauses
of the look at the window after the write, the wait for the exec, the
condition of that wait, the `jobpid` guard, the 10 s rule, the limit of
the wait (1000 ms to 1 ms), the `pcall` around `nvim_get_proc`, the read
of `/proc`, and the own name): each removal fails a test.

## On real Neovim

I ran the verifiers' own probe scripts against this head, the old head
`a7b86897` and `main`, on Neovim 0.12.3 and the 0.8-era build, with `set
hidden` and `set nohidden`. Claude is the repo's stub
`tests/bin/claude`.

- **Autosave on `WinLeave` or `FocusLost,WinLeave`**
(`autosave-probe.lua`, 8 cases per configuration): the file is saved
before Claude starts in 8 of 8 on every configuration. `main` also saves
8 of 8. The old head left it stale in 7 of 8 (a control run on 0.12.3
with `set hidden`; the verifiers saw 7 of 8 on every configuration).
- **User impact** (`design-probe.lua`: event traces and 7 user setups):
the output is byte-identical to `main` on both builds (37 lines each).
The old head differed in 15 setup rows.
- **Start matrix** (`start-matrix-d.lua`): 7 events (`WinNew`,
`WinEnter`, `WinLeave`, `BufEnter`, `BufWinEnter`, `TermOpen`,
`TermEnter`) × 8 actions (`focus_toggle` once and twice, `simple_toggle`
once and twice, `close()`, `ensure_visible()`, `open()`, `setlocal
bufhidden=wipe`) × 4 entries × 2 follow-ups, in 4 configurations: 0
cases with two Claudes, 0 untracked, 0 where a hide ended Claude. I left
out the three actions that close or wipe the split (`:close`,
`:bwipeout`, `:bwipeout!`): they show the item that is not in this PR
(the start takes your window, `USER-FILE-GONE`), as on `main`. On the
0.8-era build the groups of a `TermEnter` autocmd that hides or closes
the window segfault (exit 139). The verifiers showed that on `main`, and
in plain Neovim 0.8 without this plugin. I see the same exit 139 on this
head.
- **`BufNew` and `BufAdd`** (4 actions × 2 events): 60 cases with two
Claudes on `main`, 0 on this head.
- **Close right after a start** (a probe that runs 7 orders: a `close()`
from a `BufWinEnter`, `BufEnter` or `WinLeave` autocmd of the start, and
`open()` followed by `close()` in one call, on both builds, `hidden` and
`nohidden`; 12 runs at a time, with the stub `claude`): without the
wait, 6 of 1260 runs ended with `Caught deadly signal 'SIGHUP'` (exit 1,
at a load average of 50 to 100). With the wait of this head (`/proc`,
young jobs only), 0 of 1750 runs did, and none hung or left a Claude
running (36 CPU burners, load average 33 to 88).
- **Cost of a close** (`close-latency.lua`, 1 start and 1 close of a
settled Claude, real Neovim, load average 20 to 30): `main` 1.7 ms,
`a171a86f` 180 to 400 ms, this head 1.0 to 3.8 ms.
- **Replay** `native-start-reentry.lua` (new, in
`verify-terminal-lifecycle`; 25 checks: the 15 entries, the autosave,
both `close()` cases, the `wipe` hide, 5 `OptionSet` hides that call
`close`, `simple_toggle`, `focus_toggle`, hide and show, or swap a file
in, and a close when no process name can be read: no `/proc`, empty
`PATH`): `NVIM=<nvim>
.agents/skills/verify-terminal-lifecycle/replays/run.sh
native-start-reentry.lua` exits 0 with 0 findings on both builds. The
replay shows 23 findings on `main`, 21 on the `:noautocmd` head
(`a7b86897`), and 1 on `a171a86f` (the new check: the close raises
`ENOENT` for `ps` and leaves Claude running). The `OptionSet` checks run
after startup (`lib.after_startup`), because Neovim fires no `OptionSet`
during its startup. `run.sh` now waits for a replay that announces this,
where it ran `cquit 2` right after the file loaded.

At times the host ran at a load average of 50 to 100, and a few matrix
groups then died or showed a `DEAD-WINDOW-LEFT` flag in a "then exit"
follow-up. I reran each of those groups on its own, and none of them
showed a flag.

## `verify-terminal-lifecycle`

The skill is on `main` now (coder#525), so this PR updates it:

- The model has a phase `begin` (the `vsplit` and the new buffer exist,
the buffer has no mark) and the step `SplitRuns`. `AutocmdCalls` can run
in `begin`. A new step `CloseDuringStart` records a close that an
autocmd asked for, and `StartReturns` runs it like a typed close. The
new fact `NativeOpenSkipsDuringStart` reads `if starting then return`
and the `starting = true` / `starting = false` pair around
`pcall(start_terminal, ...)`. Without it, the model starts a second
Claude and breaks `AtMostOneClaude`. `NativeMarkBeforeTermopen` and
`OwnedUnstartedIsNotEnded` now pair with it, because the guard also
covers the entry that they covered. The new fact
`NativeRunsDeferredClose` reads the `close_requested` block of
`open_terminal`. Without it the request is lost and the Claude that the
start made runs on, which breaks `CloseStopsClaude`. The deferral in
`M.close` (`if starting then close_after_start = true`) stays a
documented limit in `SKILL.md`: the model has no wait nested in a start,
so it cannot show the close that waits inside `termopen`.
`native_terminal_toggle_spec.lua` and the replay test that half.
- The facts that read the start now read `start_terminal`, and
`close_terminal` is a forward-declared `function close_terminal()`.
- `check.sh` passes (279,492 states, was 263,060), and `--mutants` shows
every one of the 36 keys caught (26 single, 10 pairs). `SKILL.md` has
bug 10 and the replay row, drops the not-modeled item that this change
fixes, and lists the wait for the exec as not modeled (the model has no
processes).
- `run.sh` quits with 2, as before, for a replay that raises, one that
returns without calling `lib.after_startup`, and one that hangs for 60 s
(I ran all three).
- I re-recorded both locks (`verify-terminal-lifecycle` and
`verify-diff-lifecycle`; for the diff skill only the `file` line of
`native.lua` changed).

## Validation

Run on `ec480c7e`, the head of this push (it includes `main` at
`0a4ebbbc`, and all four skill locks match):

- `busted tests`: 1662 successes, 0 failures, 0 errors (33 pending).
- Integration runner: 637 passed on Neovim 0.12.3 and 619 on the 0.8-era
build, 0 failures.
- `mise run check` (syntax, `luacheck`, the static proofs of
`verify-claudecode-nvim`), `stylua --check lua tests`, `treefmt --ci`:
clean (`luacheck`: 0 warnings in 193 files).
- `verify-terminal-lifecycle/check.sh`: PASS (279,492 states),
`--mutants`: every guard is caught (36 keys, 26 single and 10 pairs; run
on `abdb9157`, and the merge of `main` since then changes no file that
the skill reads). `verify-diff-lifecycle`, `verify-mention-queue` and
`verify-selection-tracking` hash checks: pass.
- Replay `native-start-reentry.lua`: 25 checks, 0 findings on Neovim
0.12.3 and on the 0.8-era build.

## Notes for the reviewer

- Merges of `main` since `30c57e3b` also changed other skills, the diff
code and its tests, scripts, `fixtures/` (the issue-218 configs),
`mise.toml`, and docs including `AGENTS.md`. coder#569 changed
`lua/claudecode/terminal.lua` (the cwd of a terminal) and its lock
lines. None of the merges changes `lua/claudecode/terminal/`.
- A close during a start is deferred, so `:ClaudeCodeClose` from such an
autocmd returns before Claude stops. It stops when the start returns, in
the same call stack, and also when the start raises after the job
started.
- Rounds so far: Codex rounds 1 to 3 (a P1 that "the window that did not
exist before the `vsplit`" proves nothing, a P2 about a close that is
deferred past a raised start, then a clean round 3), then the two
verifiers blocked on `:noautocmd`. Round 4 (`29345666`) got two Codex P2
findings, round 5 (`fdb2b082`) got two verifier blockers and three Codex
P2 findings, round 6 (`a171a86f`) got a clean Codex round and the
verifier ISSUES that this push (round 7) answers.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant