core: confirmed keymap, tmux injection, session targeting

keys.py is the single source of truth for claude code's prompt keystrokes,
confirmed empirically against a live v2.1.191 session: bare digits select-
and-confirm immediately (no trailing enter). inject.py only ever calls
tmux send-keys (PTY injection, never OS input). target.py resolves the
active session from ~/.claude-active with a has-session guard and a single
session_name() mapping (claude-<name>) shared with the cc kit.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-25 17:55:13 -04:00
parent c61eb85748
commit 732bad4c8d
3 changed files with 222 additions and 0 deletions

84
src/claudedo/inject.py Normal file
View File

@ -0,0 +1,84 @@
"""inject keystrokes into a tmux session via ``tmux send-keys``.
this is the ONLY mechanism by which claudedo affects claude code PTY injection,
never OS-level keyboard input. it works regardless of which window is focused and
never touches Windows input or a game/anticheat's view (it is text into a linux
pseudo-terminal). do not replace this with OS keystroke injection.
"""
from __future__ import annotations
import logging
import subprocess
from . import keys, target
log = logging.getLogger(__name__)
class InjectError(Exception):
"""raised when a tmux send-keys call fails."""
def _send_keys(session: str, args: list[str], literal: bool) -> None:
cmd = ["tmux", "send-keys", "-t", session]
if literal:
cmd.append("-l")
cmd.extend(args)
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if result.returncode != 0:
err = result.stderr.decode("utf-8", "replace").strip()
raise InjectError(f"tmux send-keys failed: {err}")
def send_named(session: str, key_tokens: list[str]) -> None:
"""send a sequence of named tmux keys (e.g. ['1'] or ['Down', 'Enter'])."""
if not target.session_exists(session):
log.warning("refusing to inject — session %r does not exist", session)
return
for token in key_tokens:
_send_keys(session, [token], literal=False)
log.info("injected keys %s -> %s", key_tokens, session)
def send_literal(session: str, text: str) -> None:
"""insert literal text into the input box without submitting (``type``)."""
if not text:
return
if not target.session_exists(session):
log.warning("refusing to inject — session %r does not exist", session)
return
_send_keys(session, [text], literal=True)
log.info("injected literal text (%d chars) -> %s", len(text), session)
def perform(session: str, action) -> bool:
"""resolve a grammar.Action to keystrokes and inject them. returns acted?.
``switch`` and ``mode`` are handled by the daemon (they change daemon state, not
the claude session), so they are ignored here.
"""
name = action.name
if name == "yes":
send_named(session, keys.YES)
elif name == "no":
send_named(session, keys.NO)
elif name == "approve":
send_named(session, keys.APPROVE)
elif name == "deny":
send_named(session, keys.DENY)
elif name == "submit":
send_named(session, keys.SUBMIT)
elif name == "cancel":
send_named(session, keys.CANCEL)
elif name == "select":
seq = keys.SELECT_BY_INDEX.get(int(action.arg))
if seq is None:
log.warning("no keymap for select index %r", action.arg)
return False
send_named(session, seq)
elif name == "type":
send_literal(session, str(action.arg))
else:
return False
return True

50
src/claudedo/keys.py Normal file
View File

@ -0,0 +1,50 @@
"""the claude code prompt keymap — single source of truth.
these keystrokes were confirmed empirically against a live ``claude`` v2.1.191
session in a throwaway tmux session (capture-pane observation of benign prompts:
the folder-trust prompt and a bash-command permission prompt). do not guess or
assume if claude code changes its prompt ui, re-confirm against a live session
and update here.
confirmed behaviour (claude code v2.1.191):
- numbered select menus (trust prompt, permission prompt): pressing the bare
digit selects AND confirms IMMEDIATELY. NO trailing enter. (sending an extra
enter would leak into the next prompt or the input box.)
- arrow keys (Up/Down) move the highlight WITHOUT acting; Enter then confirms
available as a robust alternative, modelled as a sequence.
- the permission prompt is "1. Yes / 2. Yes, and don't ask again / 3. No".
- Escape backs out of / cancels a prompt ("Esc to cancel" footer).
- the main input box: literal text is inserted via ``send-keys -l`` without
submitting; a bare Enter submits.
each value is a list of tmux key tokens to send in order. a single-element list
is a single keypress. ``type`` literal text is handled separately by inject.py
via ``send-keys -l`` and is not part of this keymap.
"""
YES = ["1"]
NO = ["3"]
SELECT_1 = ["1"]
SELECT_2 = ["2"]
SELECT_3 = ["3"]
SELECT_4 = ["4"]
APPROVE = ["1"]
APPROVE_ALWAYS = ["2"]
DENY = ["3"]
SUBMIT = ["Enter"]
CANCEL = ["Escape"]
SELECT_BY_INDEX = {
1: SELECT_1,
2: SELECT_2,
3: SELECT_3,
4: SELECT_4,
}
SELECT_1_ARROW = ["Up", "Up", "Up", "Enter"]
SELECT_2_ARROW = ["Up", "Up", "Enter"]
SELECT_3_ARROW = ["Up", "Enter"]
SELECT_4_ARROW = ["Enter"]

88
src/claudedo/target.py Normal file
View File

@ -0,0 +1,88 @@
"""resolve the active claude code tmux session from ~/.claude-active."""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
log = logging.getLogger(__name__)
ACTIVE_FILE = Path.home() / ".claude-active"
SESSION_PREFIX = "claude-"
def session_name(name: str) -> str:
"""map a project short-name to its tmux session name (``claude-<name>``).
single source of truth for the name->session mapping. the shell cc kit
(~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice
commands ``switch libs`` / ``target libs`` all resolve to ``claude-libs``. an
already-prefixed name is returned unchanged so callers can pass either form.
"""
name = name.strip()
return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}"
def read_active() -> str | None:
"""return the target session name from ~/.claude-active, or None if unset."""
try:
name = ACTIVE_FILE.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
except OSError as exc:
log.warning("could not read %s: %s", ACTIVE_FILE, exc)
return None
return name or None
def write_active(name: str) -> None:
"""overwrite ~/.claude-active with a session name (used by ``switch``)."""
ACTIVE_FILE.write_text(name + "\n", encoding="utf-8")
def set_target(name: str) -> str:
"""map a project short-name via session_name() and persist it. returns the
resolved session name."""
session = session_name(name)
write_active(session)
return session
def session_exists(name: str) -> bool:
"""true if a tmux session with this name currently exists."""
if not name:
return False
result = subprocess.run(
["tmux", "has-session", "-t", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return result.returncode == 0
def resolve_target() -> str | None:
"""return the active session name only if it exists; else log and return None.
never guesses a target: on a missing/empty ~/.claude-active or a stale session
name, this logs a clear warning and returns None so the caller injects nothing.
"""
name = read_active()
if not name:
log.warning("no active session set (%s missing/empty) — run `cc` to attach", ACTIVE_FILE)
return None
if not session_exists(name):
log.warning("target session %r no longer exists — skipping injection", name)
return None
return name
# TODO: most-recently-active targeting (preferred over attached). today the target
# is "the project most recently ATTACHED to" (the cc kit writes ~/.claude-active on
# attach). upgrade to "the session claude most recently asked a question / produced
# output in" via tmux session_activity timestamps:
# tmux list-sessions -F '#{session_name} #{session_activity}'
# pick the highest-activity claude-* session; or scrape panes
# (tmux capture-pane -p -t <s>) for a waiting-prompt UI and target the session whose
# pane currently shows one.