From 732bad4c8d4dfb43aa955951844e2bf635b49110 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 25 Jun 2026 17:55:13 -0400 Subject: [PATCH] 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-) shared with the cc kit. Signed-off-by: disqualifier --- src/claudedo/inject.py | 84 ++++++++++++++++++++++++++++++++++++++++ src/claudedo/keys.py | 50 ++++++++++++++++++++++++ src/claudedo/target.py | 88 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 src/claudedo/inject.py create mode 100644 src/claudedo/keys.py create mode 100644 src/claudedo/target.py diff --git a/src/claudedo/inject.py b/src/claudedo/inject.py new file mode 100644 index 0000000..ccdabbb --- /dev/null +++ b/src/claudedo/inject.py @@ -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 diff --git a/src/claudedo/keys.py b/src/claudedo/keys.py new file mode 100644 index 0000000..679ad95 --- /dev/null +++ b/src/claudedo/keys.py @@ -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"] diff --git a/src/claudedo/target.py b/src/claudedo/target.py new file mode 100644 index 0000000..ebd7386 --- /dev/null +++ b/src/claudedo/target.py @@ -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-``). + + 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 ) for a waiting-prompt UI and target the session whose +# pane currently shows one.