Repository navigation
Fix kitty capture returning nothing - #3
Conversation
Reported in the field: `tcap` after `pwd` in kitty printed the header with no output at all. Three separate bugs, none caught by tests because the suite only drives tmux. - Wrong extent. kitty counts the running `tcap` as the current command, so `last_cmd_output` returned tcap's own empty output. Measured in a scratch window: last_cmd_output 0 bytes, last_non_empty_output 20. That extent is meant for a keybinding pressed at the prompt, where nothing is running. - Wrong window. Without `--match`, get-text reads whichever window is *focused*, not the one tcap runs in — hit while diagnosing this, where it returned an unrelated window's scrollback. Now pinned to $KITTY_WINDOW_ID. `--match` is a get-text option, so it follows the subcommand. - Wrong advice. Under `allow_remote_control socket-only` the failure told the user to set `allow_remote_control yes`, i.e. to loosen a deliberately restricted setting to fix an unrelated cause. The real fix is `listen_on`. The hint is now chosen from the mode actually in force, and covers the password mode too. `doctor` reports which channel it connected over. Caveat now documented: asking for the last non-empty output means a command that printed nothing yields an older command's output under the current header. tmux is unaffected, having real boundaries. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
No other substantive issues found. |
🤖 Claude Code ReviewReviewed the merge ref ( 1. Err(e) => Diagnostic::bad("remote control", format!("{}\n{}", channel, Self::remote_control_hint(&e, &listen_on))),
2. The fallback hint misattributes unrelated failures Any error that doesn't match the two keywords produces "kitty remote control appears to be disabled". A kitty too old for 3. Identical sequence — 4. Undocumented consequence of The module doc and README cover "last command printed nothing → older output, wrong header". The likelier case isn't covered: run Doc nit worth fixing in this PR: I was unable to run |
Both reviewers found real problems in the first pass.
Codex, rated high: with $KITTY_WINDOW_ID unset, fetch omitted --match and fell
back to kitty's *focused* window, putting another pane's scrollback — tokens,
passwords, whatever was on screen — under this command's header. Having called
unscoped capture unsafe in the same PR, it now refuses instead, and the id is
parsed as a number so a poisoned variable cannot widen the match.
Claude, four points, all taken:
- doctor discarded the underlying kitty error and printed only a keyword-guessed
hint, so anything unrecognised ("Unknown option", connection refused) reached
the user as "remote control appears disabled". It keeps {e} now, and the
fallback hint hedges rather than asserting a cause.
- fetch and doctor built the same argv independently, so a change to one would
have doctor reporting the capture path healthy while fetch failed. Extracted
get_text_args, shared by both.
- The PR added no tests. The argv order, extent choice and hint selection are
pure functions: five unit tests now lock them in, no kitty required.
- Undocumented: two tcap runs in a row return the first run's own output, since
that was the last non-empty thing printed. tcap now warns on stderr when the
preceding command was itself tcap, and both READMEs say so.
Stale AGENTS.md/README references to last_cmd_output corrected — that file is
what future agents read as spec, so a stale line there reintroduces the bug.
Verified live in kitty: warning fires only on the second consecutive run, the
missing-window path refuses, and a normal capture is unaffected.
🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
No other concrete issues found. The numeric I could not run the Rust tests or Clippy because dependencies and the crates.io index were unavailable in this environment; |
🤖 Claude Code ReviewSummaryThe core fix is right and the reasoning is well documented. Findings below, roughly in order of how much they'd bite. 1. The remaining silent-mislabel case is the one the project's own rules forbid — and it's unwarned
The new 2. The
|
Codex, second high: the password-mode hint recommended `remote_control_password "" get-text`, which removes authentication from the one call that dumps terminal contents — for any process that can reach the socket — to work around a tcap limitation. It now reports password-protected kitty as unsupported and points at tmux or a user-only Unix socket instead. A test asserts the advice never returns. Claude's main point was the deeper version of the bug this PR started from: `ls`, then a silent `cd /tmp`, then tcap gives kitty's `ls` output stamped with `cd /tmp`'s command, exit code and duration — authoritative-looking metadata over the wrong body, the exact failure the tmux coordinate work exists to prevent. The previous commit only warned when the preceding command was tcap itself. Backends now declare `has_command_boundaries()`, and captures from those that lack it are marked approximate: the annotated header carries a `# note:` line naming what the output actually is, and `--json` sets `"approximate": true`. This also fixes the hint firing for iTerm2, which does have real prompt boundaries and none of this behaviour — it was gated on `supports_history()`, a proxy for the wrong property. Also: the window-id test re-implemented the parse instead of calling it, so it could not have caught a regression in the security property it guards — `parse_window_id` is now extracted and tested directly; doctor no longer reports a remote-control failure a second time under the shell-integration label; and the `--match` doc no longer claims more isolation than instance-local ids provide. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
No other substantive security, coordinate-arithmetic, shell-hook, or data-exposure regressions found. The tmux and shell-hook paths are unchanged, tmux still wins detection, and kitty window IDs are constrained to numeric exact matches. Tests could not run because dependencies were not cached and network access to crates.io is unavailable. |
🤖 Claude Code ReviewI reviewed the merge ref. Findings below. 1. The window-id guard is effectively untested (
let got = raw.trim().parse::<u64>().ok().map(|id| format!("id:{id}"));
assert_eq!(got.as_deref(), want, "input {raw:?}");The test passes regardless of what 2. if !reachable {
return d;
}This early-returns from the middle of the 3. let reachable = run(&self.exe, &args).is_ok();
d.push(match run(&self.exe, &args) {Two subprocess round-trips for one question, and the two can disagree (socket appearing/disappearing between them), leaving 4. The socket-only keyword match may not match kitty's actual message (
5. "only your user can reach" overstates a The password hint recommends Note: I did not run |
…advice Both reviewers independently flagged the same two, and both were mine: - `window_match_requires_a_number` still re-implemented the parse instead of calling it, so the only test guarding the cross-window property could not have caught a regression in it. I believed I had fixed this last round; the patch had silently not applied. Verified this time by breaking `parse_window_id` on purpose and watching the test fail. - `kitty @ ls` was spawned twice in `diagnose`, so the two results could disagree and leave `reachable` inconsistent with the line just reported. Probed once. Also from the review: - Skipping the get-text probe when remote control is down used an early return, which dropped the `history depth` diagnostic below it. Only the probe is skipped now. - The socket keyword match required a specific word order. kitty 0.48.2 does say "Remote control is allowed over a socket only" — captured live, not invented — but matching the bare word is one less thing to break on a version bump. - The password hint offered a /tmp socket as reachable by "only your user". Connecting needs write permission on the socket, so under a group-writable umask that is more than just you. It now suggests a 0700 directory and says why /tmp is not equivalent. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
No other concrete regressions found. The tmux coordinate logic and shell hooks are unchanged, backend detection still prioritizes tmux, and the numeric window-ID validation prevents match-expression injection and accidental focused-window capture. Tests could not be run because this review environment is read-only. |
🤖 Claude Code ReviewNo changes to Three things worth addressing: 1. The So 2. The password hint recommends a path that is usually empty on macOS ( kitty expands environment variables in 3. Ok(t) if t.trim().is_empty() => Diagnostic::bad(
"shell integration",
"the extent resolved but returned nothing — kitty's own shell \
integration is probably off (`shell_integration enabled`)",
),
Minor: the |
- The approximate marker only surfaced in annotated mode and --json, so
`tcap --output | llm` under kitty could hand over an earlier command's output
with nothing anywhere saying so — the exact failure the rest of this codebase
refuses to allow. The warning now fires in every mode, on stderr so the pipe
stays clean, and --quiet still silences it. Verified both live.
- My own "safe" alternative was wrong on macOS: kitty expands env vars in
listen_on, and XDG_RUNTIME_DIR is a systemd convention that is normally unset
there, so `unix:${XDG_RUNTIME_DIR}/kitty-...` would have resolved to the
filesystem root and failed for a reason the message did not explain. The advice
now names both platforms and lives in one constant, since it was offered in
three places and only one of them had been made safe.
- doctor called a healthy setup broken: `last_non_empty_output` is also empty in
a window where nothing has printed yet, so `tcap doctor` as the first command
in a fresh window reported shell integration as failing. It now says what to do
rather than asserting a cause it cannot distinguish.
- The socket branch inferred `socket-only` from one word of kitty's message and
stated it flatly; hedged to match the unrecognised-error branch.
🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewNo actionable findings. The changes correctly:
Residual risk: kitty behavior is covered by argument-construction unit tests and live verification, but not an automated kitty integration test. I could not run the test suite because this review environment is read-only. |
🤖 Claude Code ReviewI couldn't run Overall: the fix is sound and well-targeted. The three field traps ( The kitty.conf snippet in
|
The error text exists to be pasted into kitty.conf, and it would have broken anyone who did. Verified against kitty 0.48.2 rather than assumed: a `listen_on unix:/tmp/sock # note` line produced a socket literally named `/tmp/sock # note-69755`. kitty takes the rest of the line as the value; `#` only opens a comment at the start of a line. - Trailing comments removed, and the snippet now emits one `listen_on` line for the platform it is running on. Offering both meant last-one-wins handed a macOS user the Linux line, where $XDG_RUNTIME_DIR is normally unset and expands to nothing — putting the socket at the filesystem root, the exact hazard the surrounding text warns about. - `password` is now checked before `socket`, so a password refusal naming the socket it arrived over is not answered with "restart kitty". - A window that cannot be found is diagnosed as a shared or stale $KITTY_LISTEN_ON pointing at another instance, rather than falling through to "enable remote control". The first attempt keyed on "match", which caught "Unknown option: --match" — the misdiagnosis the branch exists to prevent. The test now covers both strings. - doctor indents every line of a multi-line hint, not just the first. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
No other concrete issues found. The tmux coordinate arithmetic and shell hooks were unchanged. I could run |
🤖 Claude Code ReviewReviewed the merge ref diff ( Four things worth changing: 1. 2. 3. 4. The approximate warning fires in Two smaller notes, not blockers:
I was not able to run |
Codex asked for fail-closed rather than emit-then-warn, and it is right for the
case where the mismatch is *known*: after a previous tcap, the last non-empty
output is that capture, and `tcap --output | some-service` has already sent it
by the time stderr is read. That case now refuses with exit 1. Where a mismatch
is merely possible — the last command may have printed nothing — the capture
proceeds and is labelled, since refusing every kitty capture would remove the
backend for the common case, which works correctly.
From Claude's review:
- `socket` was tested before `no matching`/`no window`. kitty's error usually
echoes the socket path it was given, so a genuine stale-instance failure hit
the substring on the user's own path and was told to restart kitty instead of
to stop sharing a socket between instances. Specific phrases now go first.
- fetch returned Ok with an empty body, rendering as a bare header with nothing
under it — the exact symptom this PR is named for. It now fails with the same
explanation doctor gives.
- The Linux socket advice recommended $XDG_RUNTIME_DIR unconditionally, but that
is a systemd convention, absent on non-systemd distros, in containers and under
some su/ssh paths, where it expands to nothing and puts the socket at /. It is
only suggested when actually set.
- The approximate warning fired in --command mode, where the body is the shell's
exact record and kitty's text is never printed.
Also measured `--match state:self`, suggested as a way to drop the
$KITTY_WINDOW_ID dependency: it does resolve the invoking window even unfocused,
but fails without that variable ("No matching windows for expression:
state:self"), so it hides the same dependency behind a worse message. Keeping
id:N, with the finding recorded so it is not re-investigated.
🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
I could not run the test suite because this review environment is read-only. |
🤖 Claude Code ReviewI reviewed the merge ref. The core fix is sound: Three issues. 1. The function's own doc comment (lines 95-98) says kitty takes the rest of the line as the value and that this was verified live, producing a socket literally named Put the advisory prose on its own line, and use a form kitty will actually expand — 2. The It unconditionally blames 3. The "certain mismatch" refusal is narrower than its comment claims, and fires on a case it shouldn't. The guard only inspects In the other direction, Minor, non-blocking: the stderr warning at I could not run |
Codex was right that the fail-closed guard rested on `is_tcap_invocation`, which only reads command text — so `command tcap`, `env tcap`, an alias or a shell function walked straight past it and a following `tcap --output | some-service` would send the previous capture. The reliable signal was already available: tcap captures *during* a command, and the hook records that same command when it finishes. A capture now drops a marker which `__record` collects into `was_capture` on the record it writes. That holds whatever the command was called. Verified live with `alias t=tcap`: the capture through `t` is recognised and the next one refuses, though the recorded text is just "t". The text heuristic stays as a fallback for records written before the marker existed, and now also looks past `command`, `builtin`, `exec`, `env`, `nohup`, `time` and a leading backslash — with tests for each, as asked. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
The tmux backend and shell hooks are unchanged. |
🤖 Claude Code ReviewThe kitty argv fixes ( 1. The PR adds .filter(|r| !is_tcap_invocation(&r.command))With 2. The None => " listen_on unix:/run/user/$(id -u)/kitty-{kitty_pid} <- or any \
directory only you can open; $XDG_RUNTIME_DIR is unset here"The doc comment eight lines above states the invariant — kitty.conf takes the rest of the line as the value, 3. The capture marker is set even when nothing reached the screen (
4. Docs describe the pre-refusal behaviour ( Both say two No issues found in the tmux coordinate arithmetic (untouched) or the shell hooks (untouched), and nothing in the new code paths writes captured text anywhere new — the marker file contains only |
Both reviewers caught the same regression: I added `is_capture_record` but left `nth_from_end` on the old text heuristic, so a capture made through an alias was still indexed as a real command — pairing its metadata with unrelated output, on tmux as much as kitty. Fixed, with a test. Then separating two questions I had conflated: - Indexing asks "was this a tcap run at all". The text heuristic is fine there. - The refusal asks "is tcap's own text the last thing on screen". That is true only when the capture actually displayed, so it uses the marker alone. `tcap --output > notes.txt` prints nothing to the terminal, so the screen's last output is still a real command's and the next capture is valid. Gating the marker on `stdout().is_terminal()` was half the fix; while the refusal still consulted the text heuristic it kept refusing anyway. Verified live: after a redirected capture the next one succeeds, after an on-screen one it refuses. Also, the $XDG_RUNTIME_DIR-unset branch broke the paste-safety rule documented eight lines above it — a trailing explanation on the value line, plus `$(id -u)`, which kitty.conf does not substitute either. Concrete path, caveat in the prose. Docs updated: two runs in a row are now an error, not a labelled capture. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
The tmux coordinate arithmetic and shell-hook argument/status handling were unchanged and remain consistent with the documented invariants. Tests could not run because dependencies were unavailable locally and network access to crates.io failed. |
🤖 Claude Code ReviewI reviewed the merge ref (kitty argv/extent fix, The capture marker is only set on the successful-capture path, so tcap's own text still gets returned as a command's output
On kitty, Step 4 is the wrong-text-under-a-confident-header failure the PR is fixing. The marker's real meaning is "tcap put text on this screen", not "a capture succeeded". Setting it from one place at exit whenever tcap wrote to a terminal — covering stderr and
|
Codex found a real vulnerability class, and it predates the marker: state files live at predictable paths under a directory that on Linux falls back to the shared /tmp, and plain writes follow symlinks. Another local user could pre-create the directory and leave a symlink where a state file goes, and tcap would truncate whatever it pointed at. The directory is now created 0700 and checked with symlink_metadata to be a real directory owned by the caller, and every state write opens with O_NOFOLLOW and mode 0600 — the log as much as the marker, since the log always had the same exposure. Verified by planting symlinks at both exact paths: the marker write leaves the target intact and the log write fails with ELOOP. Both reviewers also noted the marker was set only on the successful stdout path, while warnings and errors still print to the terminal — so `tcap --output | svc` leaves its warning on kitty's screen unmarked, and the next capture could return that. Marking now happens for any tcap output that reaches a terminal, stdout or stderr, success or failure, excluding the record hook which prints nothing. libc is added for getuid, O_NOFOLLOW and the mode bits, replacing a hand-rolled extern "C" getuid. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
The kitty argument ordering, numeric window matching, and |
🤖 Claude Code ReviewThe kitty argv fixes (
The marker is set for any subcommand whose stdout or stderr is a tty. The installed usage is Consequences, on a fresh shell:
This is invisible to the e2e suite only because Restrict the mark to the capture path (
The comment at clap's own output bypasses the marker entirely —
No issues found in the tmux coordinate arithmetic (untouched here), the shell hooks ( |
…exit The previous commit's "mark if a terminal is attached" was wrong three ways, all found by review: - `eval "$(tcap init zsh)"` pipes stdout but leaves stderr a tty, so every shell startup marked itself. The marker then survived to the user's first real command, which was filtered out of indexing and hard-refused on kitty — naming "tcap" for a command that was `ls`. This hit tmux users too, and the e2e suite missed it only because it redirects both streams during setup, which real installs do not. - `tcap --output > notes.txt` marked despite displaying nothing, the exact false refusal the commit claimed to fix. - clap prints `--help`, `--version` and usage errors itself and exits, so none of the marking ran at all: `tcap --help` then `tcap` returned the help text as a command's output. Marking now happens at each write, gated on that stream being a terminal: stdout for captures, doctor and init; stderr for hints and errors; clap routed through try_parse so its own output counts. The record hook prints nothing and so never marks. Verified live: a fresh shell captures its first real command. Also from review: state reads bypassed the checks writes had just gained — a pre-created directory with a crafted log could inject records whose command text is printed back, escape sequences included. Reads go through the same ownership check and O_NOFOLLOW now. And `set_permissions` ran before the symlink check, so a planted symlink would have had its target chmod'ed before being rejected; the directory is created 0700 up front and inspected before anything is mutated. Both verified by planting symlinks: the write fails with ELOOP, the read returns nothing, and a symlinked state dir is refused with its target's mode left untouched. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewNo actionable findings. The kitty fix correctly:
Shell hooks are unchanged, so the Verification limitation: |
🤖 Claude Code ReviewI reviewed the merge ref against Findings1. The flagship workflow is
The 2. The marker poisons any command that merely calls tcap, and the refusal message then misreports what ran. (
3. iTerm2 inherits The shim calls Looks good
Nit
|
…ding The shim write was the same vulnerability class, in a path the last commit missed: `iterm2_capture.py` was written with a plain `fs::write` to a predictable state path and then handed to `python3`. A symlink or pre-created file there is code execution, not a truncated file. It goes through the ownership-checked, O_NOFOLLOW path now, and an audit found no other unhardened state writes. Wording, both from review: - The `# note:` line and the stderr hint asserted the body was an *earlier* command's. After `tcap --output | llm` the downstream tool's output is what is on screen, so it can as easily belong to a later one. Both now say it may not belong to the named command, and name the two ways that happens. - The refusal said "the previous command was tcap itself". The marker means tcap's output is on screen, which a script that merely calls tcap also satisfies, so it no longer claims what ran. iTerm2's `has_command_boundaries()` is now explicitly flagged as an assumption: `async_get_last_prompt` may resolve to the prompt tcap is running under, which is the same trap that made kitty's `last_cmd_output` return tcap's own empty output. Nobody has run that backend; `tcap doctor` under iTerm2 would settle it. README test count corrected to 59. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code Review
No other concrete issues found. The kitty extent/window argument construction is correct, tmux still wins detection, tmux coordinate conversion remains absolute-to-relative, and the shell-hook invariants are unchanged. Tests could not run because dependencies are unavailable locally and network access to crates.io is restricted. |
🤖 Claude Code ReviewThe core of the change — the kitty argv fix, the Three things worth addressing. 1.
|
A regression I introduced two commits ago, and the reverse of what an earlier round advised: `nth_from_end` was switched to `is_capture_record`, which ORs in `was_capture`. But that flag means "tcap wrote to this terminal during this command", which is equally true of a wrapper script, a Makefile target or a git alias that calls tcap. Those were being skipped entirely, so `-c 1` returned the command before them — on tmux too, where boundaries are exact and none of this machinery applies, and with header and body mutually consistent so nothing looks wrong. Indexing is back on the text check. The two signals answer different questions and now stay in their own places: `was_capture` gates the refusal, the text check gates indexing. Conflating them has now broken each direction once, so there is a test asserting they are independent. The cost is accepting that an aliased capture is indexable — visibly odd, where silently skipping someone's deploy script is not. Also: - The `# note:` was dropped whenever there was no other metadata, which is exactly kitty without shell integration — approximate body, nothing in-band to say so. It now renders on its own. - `secure_read` returning None for a refused directory made `load` look like a fresh session, silently switching off the guard built on it. The reason is surfaced instead. - `mark_capture` swallowed write failures, so a full or read-only filesystem left the guard off unannounced. It reports now, without being fatal: refusing to capture because a temp file is unwritable would be the worse trade. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFound two issues:
The PR does not change I could not run the test suite because the environment has no cached dependencies and network access to crates.io is unavailable. |
🤖 Claude Code ReviewI reviewed the merge ref ( Overall this is careful work. The three kitty fixes ( Two things worth changing. 1. On kitty with shell integration installed, run
The behaviour also flips on an unrelated flag: The marker's stated meaning is "is tcap's own captured text the last thing on screen" (the comment at 2. The new refusal logic has no test —
Minor, non-blocking:
|
Marking on stderr made `tcap --output | llm` refuse the *next* capture, because the hint printed to a tty while the captured text went to the pipe — and the outcome flipped on whether --quiet was passed. A one-line diagnostic is not the text this guard is about. Marking now happens only where captured content is printed: the capture itself, doctor, init, and --help. Errors and hints do not mark, so a failed `tcap -c 2` no longer poisons the following capture. kitty's empty check used `trim()`, which discarded legitimate whitespace-only output such as `printf ' \n'` — and --raw/--output promise exact bytes. Only newlines now count as nothing. Adds the missing round-trip test for the marker file, which decides whether a capture is refused and had no coverage at all. Documents the one case the marker cannot see: after `tcap --output | llm` the downstream tool's answer is what is on screen, with no marker set, so the next capture is labelled rather than refused. tcap cannot observe what another process printed; that is a limitation to state, not a check to add. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewNo actionable findings. The kitty fix looks sound:
I could not execute the test suite because dependencies are not cached and network access to crates.io is unavailable. Static inspection found no regression in the reviewed paths. The remaining residual risk is the already-documented lack of automated/live kitty and iTerm2 coverage; iTerm2 remains unverified. |
🤖 Claude Code ReviewReviewed the merge ref (
The state-directory hardening (0700 at creation, Findings below. iTerm2 still falls back to the focused session — the leak the kitty change just closed
if session is None:
# Fall back to whatever is focused, e.g. when ITERM_SESSION_ID is unset
window = app.current_terminal_windowThis is exactly the hazard
|
tcapafterpwdin kitty printed the header with no output. Three bugs, nonecovered by tests because the suite only drives tmux.
Wrong extent — kitty counts the running
tcapas the current command, solast_cmd_outputreturned tcap's own empty output. Measured in a scratch window:last_cmd_outputlast_non_empty_outputWrong window — without
--match,get-textreads whichever window isfocused, not the one tcap runs in. Now pinned to
$KITTY_WINDOW_ID.Wrong advice — under
allow_remote_control socket-onlythe error told theuser to set
allow_remote_control yes, loosening a deliberately restrictedsetting to fix an unrelated cause. The real fix is
listen_on.Verified live in kitty for both the socket and escape-code paths.
Caveat documented: the last non-empty output means a silent command yields an
older command's output under the current header. tmux is unaffected.
Generated with SMT smt@agora.build