from __future__ import annotations
import argparse
import hashlib
import signal
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from types import FrameType
LOG_PATH = Path.home() / ".netsky" / "state" / "permissions-watcher.log"
DEFAULT_TARGET = "agent0"
DEFAULT_INTERVAL = 2.0
CAPTURE_TIMEOUT = 5.0
SEND_TIMEOUT = 5.0
LOG_ROTATE_BYTES = 5 * 1024 * 1024
PROMPT_NEEDLE = "Do you want to"
YES_NEEDLE = "1. Yes"
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _rotate_if_needed(log_path: Path) -> None:
try:
if log_path.stat().st_size < LOG_ROTATE_BYTES:
return
except OSError:
return
rotated = log_path.with_suffix(log_path.suffix + ".1")
try:
if rotated.exists():
rotated.unlink()
log_path.rename(rotated)
except OSError:
pass
def log(msg: str) -> None:
line = f"{_now()} {msg}\n"
try:
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
_rotate_if_needed(LOG_PATH)
with LOG_PATH.open("a") as f:
f.write(line)
except OSError as exc:
sys.stderr.write(f"log-write-failed: {exc}: {line}")
sys.stdout.write(line)
sys.stdout.flush()
def capture_pane(target: str) -> str | None:
try:
result = subprocess.run(
["tmux", "capture-pane", "-t", target, "-p"],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=CAPTURE_TIMEOUT,
check=False,
)
except (subprocess.TimeoutExpired, OSError) as exc:
log(f"capture-error target={target} err={exc!r}")
return None
if result.returncode != 0:
return None
return result.stdout
def detect_prompt(pane: str) -> bool:
return PROMPT_NEEDLE in pane and YES_NEEDLE in pane
def send_approval(target: str) -> bool:
try:
subprocess.run(
["tmux", "send-keys", "-t", target, "-l", "1"],
timeout=SEND_TIMEOUT,
check=True,
)
subprocess.run(
["tmux", "send-keys", "-t", target, "Enter"],
timeout=SEND_TIMEOUT,
check=True,
)
except (
subprocess.TimeoutExpired,
subprocess.CalledProcessError,
OSError,
) as exc:
log(f"send-error target={target} err={exc!r}")
return False
return True
def hash_pane(pane: str) -> str:
return hashlib.sha256(pane.encode("utf-8", "replace")).hexdigest()[:16]
def run(target: str, interval: float) -> None:
log(f"watcher-start target={target} interval={interval}s log={LOG_PATH}")
awaiting_clear = False
def shutdown(signum: int, _frame: FrameType | None) -> None:
log(f"watcher-stop signal={signum}")
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
while True:
try:
pane = capture_pane(target)
if pane is None:
time.sleep(interval)
continue
if not detect_prompt(pane):
if awaiting_clear:
log(f"prompt-cleared target={target}")
awaiting_clear = False
time.sleep(interval)
continue
if awaiting_clear:
time.sleep(interval)
continue
h = hash_pane(pane)
log(f"prompt-detected hash={h} target={target}")
if send_approval(target):
log(f"approval-sent hash={h} keys=1+Enter target={target}")
awaiting_clear = True
else:
log(f"approval-failed hash={h} target={target}")
except Exception as exc: log(f"loop-error err={exc!r}")
time.sleep(interval)
def main() -> int:
parser = argparse.ArgumentParser(
description=("Auto-approve Claude Code permission prompts via tmux send-keys.")
)
parser.add_argument(
"--target",
default=DEFAULT_TARGET,
help=f"tmux session/pane id (default: {DEFAULT_TARGET})",
)
parser.add_argument(
"--interval",
type=float,
default=DEFAULT_INTERVAL,
help=f"poll interval seconds (default: {DEFAULT_INTERVAL})",
)
args = parser.parse_args()
if args.interval <= 0:
parser.error("--interval must be positive")
try:
run(args.target, args.interval)
except SystemExit:
raise
except Exception as exc: log(f"watcher-fatal err={exc!r}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())