Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .omx/state/active-sessions/mcp-105938.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"repoRoot": "/home/deadpool/Documents/flashpaste",
"branch": "main",
"taskName": "Session start: mcp-connect",
"latestTaskPreview": "Session start: mcp-connect",
"agentName": "unknown",
"cliName": "unknown",
"worktreePath": "/home/deadpool/Documents/flashpaste",
"taskMode": "",
"openspecTier": "",
"taskRoutingReason": "colony hook cwd binding",
"startedAt": "2026-06-10T10:08:41.538Z",
"lastHeartbeatAt": "2026-06-10T10:08:41.538Z",
"state": "working",
"sessionKey": "mcp-105938"
}
17 changes: 17 additions & 0 deletions .omx/state/active-sessions/mcp-1614506.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"repoRoot": "/home/deadpool/Documents/flashpaste",
"branch": "fix/paste-double-dedup-window-and-kitty-socket",
"taskName": "Session start: mcp-connect",
"latestTaskPreview": "Session start: mcp-connect",
"agentName": "unknown",
"cliName": "unknown",
"worktreePath": "/home/deadpool/Documents/flashpaste",
"taskMode": "",
"openspecTier": "",
"taskRoutingReason": "colony hook cwd binding",
"startedAt": "2026-06-10T14:37:26.452Z",
"lastHeartbeatAt": "2026-06-10T14:37:26.452Z",
"state": "working",
"sessionKey": "mcp-1614506"
}
17 changes: 17 additions & 0 deletions .omx/state/active-sessions/mcp-1637503.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"repoRoot": "/home/deadpool/Documents/flashpaste",
"branch": "main",
"taskName": "Session start: mcp-connect",
"latestTaskPreview": "Session start: mcp-connect",
"agentName": "unknown",
"cliName": "unknown",
"worktreePath": "/home/deadpool/Documents/flashpaste",
"taskMode": "",
"openspecTier": "",
"taskRoutingReason": "colony hook cwd binding",
"startedAt": "2026-06-10T09:29:57.216Z",
"lastHeartbeatAt": "2026-06-10T09:29:57.216Z",
"state": "working",
"sessionKey": "mcp-1637503"
}
17 changes: 17 additions & 0 deletions .omx/state/active-sessions/mcp-1734424.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"repoRoot": "/home/deadpool/Documents/flashpaste",
"branch": "main",
"taskName": "Session start: mcp-connect",
"latestTaskPreview": "Session start: mcp-connect",
"agentName": "unknown",
"cliName": "unknown",
"worktreePath": "/home/deadpool/Documents/flashpaste",
"taskMode": "",
"openspecTier": "",
"taskRoutingReason": "colony hook cwd binding",
"startedAt": "2026-06-10T09:44:37.598Z",
"lastHeartbeatAt": "2026-06-10T09:44:37.598Z",
"state": "working",
"sessionKey": "mcp-1734424"
}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Release-tag policy: every `vX.Y` commit on `main` must be tagged and have a matc

## [Unreleased]

### Fixed

- Double-paste under XWayland kitty, round two. The kitty `ctrl+v` interception is retired: under XWayland, `kitten @ ls` reports `is_focused=false` for every OS window and empty `foreground_processes`, so `kitty-paste-router.sh`'s tmux detection could never succeed. Every paste took the slow fallthrough (a background `launch` that lands seconds late under load), and the late second fire fell outside the daemon's 2.5 s dedup window — observed as identical 162-byte pastes 4 s apart. `config/keybindings.canonical` now requires kitty `ctrl+v` to be `UNBOUND` (new canonical keyword, enforced by `flashpaste-keybindings-check.sh`); tmux's `bind -n C-v` is the single Ctrl+V handler (~5 ms, pane id always from the pressing client). In non-tmux kitty windows the raw `0x16` reaches Claude Code, which reads the clipboard itself via the `wl-paste` shim; plain shells use kitty's native `ctrl+shift+v`.

### Added

- `flashpaste-keybindings-check.sh`: `UNBOUND` keyword in `keybindings.canonical` asserts a key must NOT be bound on a surface (previously only positive "must contain" rules existed).
- `kitty-paste-router.sh` now traces to the shared clipboard-pipeline log (`paste-router` tag: invoked / suppressed / fallthrough / pane-resolved / done / image-fallback), closing the observability gap that made this regression invisible — the router was the only paste path that wrote no logs.

## [1.34] - 2026-05-21

### Fixed
Expand Down
14 changes: 12 additions & 2 deletions bin/flashpaste-keybindings-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,20 @@ while read -r surface key want; do
# Escape ERE metacharacters in the key (e.g. the '+' in ctrl+v).
key_re=$(printf '%s' "$key" | sed 's/[][\\.^$*+?(){}|]/\\&/g')
case "$surface" in
kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+${key_re}[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+${key_re}[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
*) echo " ? unknown surface '$surface' in canonical"; drift=1; continue ;;
esac
if [ "$want" = "UNBOUND" ]; then
if [ -z "$line" ]; then
echo " ✓ $surface $key -> unbound (as required)"
else
echo " ✗ $surface $key: must be UNBOUND but a live binding exists"
echo " live: $line"
drift=1
fi
continue
fi
if [ -z "$line" ]; then
echo " ✗ $surface $key: no binding found in $conf"
drift=1
Expand Down
19 changes: 19 additions & 0 deletions bin/kitty-paste-router.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,22 @@
# this adds zero latency to the actual paste. When NOT in tmux, kitty's
# handler is the only one and ~30ms there is unnoticeable.
# ─────────────────────────────────────────────────────────────────────
# STATUS (2026-06-10): RETIRED from the default keybindings. Under XWayland
# kitty, `kitten @ ls` reports is_focused=false for every window and empty
# foreground_processes, so focused_window_is_tmux() can never succeed; every
# paste took the slow fallthrough (background launch, seconds under load) and
# a late second fire landed outside the daemon's dedup window -> double paste.
# config/keybindings.canonical now requires kitty ctrl+v to be UNBOUND; tmux's
# own `bind -n C-v` is the single handler. This script remains for
# native-Wayland setups that still map it explicitly.
set -u

# Trace to the shared clipboard-pipeline log (same stream as paste_image.sh /
# tmux-paste-dispatch.sh) so a real Ctrl+V shows which branch fired here.
. "$(dirname -- "$0")/clip-pipeline-log.sh" 2>/dev/null || true
type clog >/dev/null 2>&1 || clog() { :; }
clog "paste-router" "event=invoked" "KITTY_LISTEN_ON='${KITTY_LISTEN_ON:-}'" "KITTY_WINDOW_ID='${KITTY_WINDOW_ID:-}'"

PASTE_IMAGE_FALLBACK="${FLASHPASTE_IMAGE_FALLBACK:-/home/deadpool/paste_image.sh}"
# Resolve kitty's remote-control socket.
#
Expand Down Expand Up @@ -72,16 +86,21 @@ focused_window_is_tmux() {

if focused_window_is_tmux; then
# tmux's `bind -n C-v` owns this paste. Do nothing.
clog "paste-router" "event=suppressed" "reason=focused-window-is-tmux" "sock='$KITTY_SOCK'"
exit 0
fi
clog "paste-router" "event=fallthrough" "reason=tmux-not-detected" "sock='$KITTY_SOCK'"

# Not in tmux (or undetectable) — run the original kitty paste path. The
# daemon dedups if this turns out to be a duplicate of tmux's fire.
pane="$(tmux display-message -p "#{pane_id}" 2>/dev/null)"
# Only call the daemon trigger when we actually have a pane id. An empty
# pane (no tmux at all) can't be a paste target, so go straight to the
# image fallback instead of handing the daemon a blank pane.
clog "paste-router" "event=pane-resolved" "pane='$pane'"
if [ -n "$pane" ] && flashpaste-trigger "$pane" 2>/dev/null; then
clog "paste-router" "event=done" "via=flashpaste-trigger" "pane='$pane'"
exit 0
fi
clog "paste-router" "event=image-fallback" "exec='$PASTE_IMAGE_FALLBACK'"
exec "$PASTE_IMAGE_FALLBACK"
14 changes: 11 additions & 3 deletions config/keybindings.canonical
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@
# Format: one rule per non-comment line: <surface> <key> <must-contain-substring>
# The check passes when the live config's binding for <key> contains the
# substring. Keep substrings stable (script basenames), not full command lines.
# The special substring UNBOUND inverts the rule: the surface must NOT bind
# the key at all.

# kitty's Ctrl+V must route through the tmux-aware router (suppresses kitty's
# handler inside tmux so only tmux's bind fires — see kitty-paste-router.sh).
kitty ctrl+v kitty-paste-router.sh
# kitty must NOT intercept Ctrl+V (retired 2026-06-10). The tmux-aware router
# needed `kitten @ ls` focus + foreground-process detection, which XWayland
# kitty does not provide (every window reports is_focused=false and empty
# foreground_processes), so the router always took the slow fallthrough
# (background launch, seconds under load) and its late second fire landed
# outside the daemon's dedup window -> double paste. With no kitty map the
# key reaches the application: tmux's own bind handles tmux windows (~5ms,
# correct pane), and Claude Code reads the clipboard itself elsewhere.
kitty ctrl+v UNBOUND

# tmux's root Ctrl+V must hit the fast Rust trigger (daemon), falling back to
# the bash dispatcher when the daemon is down.
Expand Down
28 changes: 21 additions & 7 deletions tests/keybindings-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,40 @@ check() { # desc want_rc actual_rc
else echo "FAIL - $1 : want rc=$2 got rc=$3"; fail=$((fail+1)); fi
}

# Consistent fixtures.
printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.ok"
# Consistent fixtures: kitty leaves ctrl+v unbound (canonical: UNBOUND since
# 2026-06-10 — XWayland kitty broke the router's tmux detection), tmux binds
# C-v to the trigger.
printf '# ctrl+v intentionally unmapped\nmap ctrl+shift+v paste_from_clipboard\n' > "$T/kitty.ok"
printf 'bind -n C-v run-shell -b "flashpaste-trigger %%pane"\n' > "$T/tmux.ok"
CANONICAL="$CANON" KITTY_CONF="$T/kitty.ok" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
check "consistent configs -> rc 0" 0 $?

# Drift: kitty routes through the OLD inline path, not the router.
printf 'map ctrl+v launch -- sh -c flashpaste-trigger\n' > "$T/kitty.drift"
# Drift: kitty still intercepts ctrl+v (any binding violates UNBOUND).
printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.drift"
CANONICAL="$CANON" KITTY_CONF="$T/kitty.drift" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
check "kitty drift (no router) -> rc 1" 1 $?
check "kitty ctrl+v bound despite UNBOUND -> rc 1" 1 $?

# Missing tmux binding entirely.
printf '# no C-v here\n' > "$T/tmux.missing"
CANONICAL="$CANON" KITTY_CONF="$T/kitty.ok" TMUX_CONF="$T/tmux.missing" bash "$CHECK" >/dev/null 2>&1
check "missing tmux binding -> rc 1" 1 $?

# The literal '+' in ctrl+v must match literally, not as an ERE quantifier.
# The literal '+' in ctrl+v must match literally, not as an ERE quantifier:
# a 'ctrlv' binding must NOT count as a ctrl+v binding, so UNBOUND still
# passes (rc 0) — proving the key regex didn't degrade into 'ctrl.v'.
printf 'map ctrlv launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.plusbug"
CANONICAL="$CANON" KITTY_CONF="$T/kitty.plusbug" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
check "ctrl+v not matched by 'ctrlv' (literal +) -> rc 1" 1 $?
check "literal + : 'ctrlv' binding does not violate ctrl+v UNBOUND -> rc 0" 0 $?

# Positive-substring rules still work: a canonical that REQUIRES the router
# must fail when kitty routes elsewhere, and pass when it matches.
printf 'kitty ctrl+v kitty-paste-router.sh\ntmux C-v flashpaste-trigger\n' > "$T/canon.positive"
printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.router"
CANONICAL="$T/canon.positive" KITTY_CONF="$T/kitty.router" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
check "positive rule: router binding matches -> rc 0" 0 $?
printf 'map ctrl+v launch -- sh -c flashpaste-trigger\n' > "$T/kitty.inline"
CANONICAL="$T/canon.positive" KITTY_CONF="$T/kitty.inline" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
check "positive rule: non-router binding drifts -> rc 1" 1 $?

echo "--- keybindings-check: PASS=$pass FAIL=$fail"
[ "$fail" = "0" ]
Loading