feat: output-handler seam with tmux and stdout handlers

extract an OutputHandler abstract base; TmuxOutputHandler is production
(send-keys, PTY-only), StdoutOutputHandler prints what would be injected so
grammar+keymap run end-to-end without a live claude session (the deterministic
test path). module-level shims default to tmux so the daemon is unchanged.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-25 18:42:34 -04:00
parent d43004e4b9
commit 84c74603e5

View File

@ -1,15 +1,24 @@
"""inject keystrokes into a tmux session via ``tmux send-keys``.
"""output handlers: resolve a grammar.Action to keystrokes and emit them.
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.
the production handler (TmuxOutputHandler) injects via ``tmux send-keys`` 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. this is also why claudedo is a
standalone daemon and not an MCP server MCP tools can only return content to claude,
not inject into its input stream.
StdoutOutputHandler prints what WOULD be injected instead of touching tmux, so the
grammar + keymap can be exercised end-to-end without a live claude session the
deterministic test path. both implement the same OutputHandler seam and are
interchangeable.
"""
from __future__ import annotations
import logging
import subprocess
from abc import ABC, abstractmethod
from . import keys, target
@ -17,10 +26,62 @@ log = logging.getLogger(__name__)
class InjectError(Exception):
"""raised when a tmux send-keys call fails."""
"""raised when a tmux send-keys call fails"""
def _send_keys(session: str, args: list[str], literal: bool) -> None:
class OutputHandler(ABC):
"""abstract sink for resolved keystrokes.
concretes implement send_named (a sequence of named tmux keys) and send_literal
(literal text, no submit). perform() maps a grammar.Action onto these and is shared
by all handlers.
"""
@abstractmethod
def send_named(self, session: str, key_tokens: list[str]) -> None:
"""emit a sequence of named keys (e.g. ['1'] or ['Down', 'Enter'])"""
@abstractmethod
def send_literal(self, session: str, text: str) -> None:
"""emit literal text into the input box without submitting (``type``)"""
def perform(self, session: str, action) -> bool:
"""resolve a grammar.Action to keystrokes and emit 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":
self.send_named(session, keys.YES)
elif name == "no":
self.send_named(session, keys.NO)
elif name == "approve":
self.send_named(session, keys.APPROVE)
elif name == "deny":
self.send_named(session, keys.DENY)
elif name == "submit":
self.send_named(session, keys.SUBMIT)
elif name == "cancel":
self.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
self.send_named(session, seq)
elif name == "type":
self.send_literal(session, str(action.arg))
else:
return False
return True
class TmuxOutputHandler(OutputHandler):
"""production handler — injects keystrokes into a tmux session via send-keys"""
@staticmethod
def _send_keys(session: str, args: list[str], literal: bool) -> None:
cmd = ["tmux", "send-keys", "-t", session]
if literal:
cmd.append("-l")
@ -30,55 +91,68 @@ def _send_keys(session: str, args: list[str], literal: bool) -> None:
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'])."""
def send_named(self, session: str, key_tokens: list[str]) -> None:
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)
self._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``)."""
def send_literal(self, session: str, text: str) -> None:
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)
self._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?.
class StdoutOutputHandler(OutputHandler):
"""test handler — prints what would be injected instead of touching tmux.
``switch`` and ``mode`` are handled by the daemon (they change daemon state, not
the claude session), so they are ignored here.
no session existence check (there is no real session); lets grammar + keymap be
exercised end-to-end without a live claude session. records the last emission on
``self.last`` for assertions.
"""
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
def __init__(self, stream=None) -> None:
import sys
self.stream = stream if stream is not None else sys.stdout
self.last: tuple[str, object] | None = None
def send_named(self, session: str, key_tokens: list[str]) -> None:
self.last = ("named", list(key_tokens))
print(f"[stdout] keys {key_tokens} -> {session}", file=self.stream)
def send_literal(self, session: str, text: str) -> None:
if not text:
return
self.last = ("literal", text)
print(f"[stdout] literal {text!r} -> {session}", file=self.stream)
_default_handler: OutputHandler = TmuxOutputHandler()
def set_default_handler(handler: OutputHandler) -> None:
"""swap the module-level handler the daemon drives (tmux in prod, stdout in tests)"""
global _default_handler
_default_handler = handler
def send_named(session: str, key_tokens: list[str]) -> None:
"""module-level shim delegating to the default handler"""
_default_handler.send_named(session, key_tokens)
def send_literal(session: str, text: str) -> None:
"""module-level shim delegating to the default handler"""
_default_handler.send_literal(session, text)
def perform(session: str, action) -> bool:
"""module-level shim delegating to the default handler"""
return _default_handler.perform(session, action)