claude-i A developer created claude-i, a tool that runs Claude AI prompts through an interactive tmux session rather than the standard `claude -p` command. The tool uses a Stop hook gated on a `$CLAUDE_I_SENTINEL` environment variable to capture the assistant's response, then terminates the tmux session automatically. | /usr/bin/env python3 | | | """claude-i: like claude -p , but driven through an interactive Claude session. | | | Architecture: | | | - tmux session hosts an interactive claude kill-session nukes the whole | | | process tree — pty alone can't do this reliably . | | | - A Stop hook one-time install into ~/.claude/settings.json , gated on | | | $CLAUDE I SENTINEL, writes the hook payload and touches a sentinel. | | | - We wait on the sentinel, read the transcript path from the payload, | | | extract the last assistant message, then kill the tmux session. | | | Run with --verbose to tail the tmux pane while it runs debug hangs . | | | """ | | | import argparse, json, os, shlex, subprocess, sys, tempfile, threading, time | | | from pathlib import Path | | | HOOK CMD = | | | 'if -n "$CLAUDE I SENTINEL" ; then ' | | | 'cat "$CLAUDE I SENTINEL.json"; ' | | | 'touch "$CLAUDE I SENTINEL"; ' | | | 'fi' | | | | | | SETTINGS = Path.home / ".claude" / "settings.json" | | | def hook installed - bool: | | | if not SETTINGS.exists : | | | return False | | | try: | | | cfg = json.loads SETTINGS.read text | | | except json.JSONDecodeError: | | | return False | | | return any | | | h.get "command" == HOOK CMD | | | for g in cfg.get "hooks", {} .get "Stop", | | | for h in g.get "hooks", | | | | | | def install hook - None: | | | SETTINGS.parent.mkdir parents=True, exist ok=True | | | cfg = {} | | | if SETTINGS.exists : | | | try: | | | cfg = json.loads SETTINGS.read text | | | except json.JSONDecodeError: | | | sys.exit f"{SETTINGS} is not valid JSON; refusing to touch it" | | | cfg.setdefault "hooks", {} .setdefault "Stop", .append | | | {"hooks": {"type": "command", "command": HOOK CMD} } | | | | | | SETTINGS.write text json.dumps cfg, indent=2 | | | def ensure hook - None: | | | if hook installed : | | | return | | | print f"claude-i needs a Stop hook in {SETTINGS}.", file=sys.stderr | | | print "Gated on $CLAUDE I SENTINEL, so it won't affect normal Claude use.", file=sys.stderr | | | print f" command: {HOOK CMD}", file=sys.stderr | | | if input "Install it now? y/N " .strip .lower = "y": | | | sys.exit "aborted" | | | install hook | | | print "Installed. Active on the next Claude session.", file=sys.stderr | | | print "If the first run hangs, run claude interactively once, type /hooks,", file=sys.stderr | | | print "acknowledge the change, then exit and retry.", file=sys.stderr | | | def tmux args: str, check: bool = True - subprocess.CompletedProcess: | | | return subprocess.run "tmux", args , capture output=True, text=True, check=check | | | def tail pane session: str, stop event: threading.Event - None: | | | """Stream tmux pane content to stderr until stop event is set.""" | | | last = "" | | | while not stop event.is set : | | | try: | | | out = tmux "capture-pane", "-pt", session, check=False .stdout | | | except Exception: | | | break | | | if out = last: | | | sys.stderr.write "\033 2J\033 H" clear + reprint pane snapshot | | | sys.stderr.write out | | | sys.stderr.flush | | | last = out | | | time.sleep 0.3 | | | def run prompt: str, extra args: list str , verbose: bool, | | | ready wait: float, timeout: int - str: | | | sentinel = Path tempfile.mktemp prefix="claude-i-", suffix=".done" | | | payload = Path str sentinel + ".json" | | | session = f"claude-i-{os.getpid }" | | | Build the claude command for sh -c . shlex.quote is essential — naive | | | interpolation breaks on prompts containing spaces or shell metachars. | | | parts = f"CLAUDE I SENTINEL={shlex.quote str sentinel }", "exec", "claude" | | | parts.extend shlex.quote a for a in extra args | | | claude cmd = " ".join parts | | | tmux "new-session", "-d", "-s", session, "-x", "220", "-y", "50", | | | "sh", "-c", claude cmd | | | tail stop = threading.Event | | | tail thread = None | | | if verbose: | | | tail thread = threading.Thread target=tail pane, args= session, tail stop , daemon=True | | | tail thread.start | | | try: | | | Let the TUI come up. | | | time.sleep ready wait | | | Paste the prompt multiline-safe and submit. | | | tmux "set-buffer", "-b", session, prompt | | | tmux "paste-buffer", "-t", session, "-b", session | | | tmux "send-keys", "-t", session, "Enter" | | | Wait for Stop hook. | | | deadline = time.time + timeout | | | while not sentinel.exists : | | | if time.time deadline: | | | raise TimeoutError | | | f"No Stop hook signal after {timeout}s. Likely causes:\n" | | | f" - Hook not yet active run claude once, /hooks, acknowledge \n" | | | f" - TUI never received the prompt try --ready-wait 8 \n" | | | f" - Re-run with --verbose to watch the tmux pane" | | | | | | time.sleep 0.3 | | | Parse hook payload → transcript → last assistant text. | | | if not payload.exists : | | | return " hook fired but no payload written " | | | hook input = json.loads payload.read text | | | transcript = Path hook input.get "transcript path", "" | | | if not transcript.exists : | | | return f" transcript missing: {transcript} " | | | last = None | | | for line in transcript.read text .splitlines : | | | try: | | | msg = json.loads line | | | except json.JSONDecodeError: | | | continue | | | if msg.get "message", {} .get "role" == "assistant": | | | last = msg "message" | | | if not last: | | | return "" | | | return "".join | | | b.get "text", "" for b in last.get "content", | | | if b.get "type" == "text" | | | | | | finally: | | | tail stop.set | | | if tail thread: | | | tail thread.join timeout=1 | | | The whole point: kill-session reaps the entire process tree. | | | tmux "kill-session", "-t", session, check=False | | | for p in sentinel, payload : | | | try: p.unlink | | | except FileNotFoundError: pass | | | def main - None: | | | ap = argparse.ArgumentParser description= doc .splitlines 0 | | | ap.add argument "prompt" | | | ap.add argument "-v", "--verbose", action="store true", | | | help="tail the tmux pane to stderr debug hangs " | | | ap.add argument "--ready-wait", type=float, default=4.0, | | | help="seconds to let the TUI start before sending prompt" | | | ap.add argument "--timeout", type=int, default=600, | | | help="seconds to wait for Stop hook before failing" | | | ap.add argument "extra", nargs=argparse.REMAINDER, | | | help="extra args forwarded to claude" | | | args = ap.parse args | | | ensure hook | | | print run args.prompt, args.extra, args.verbose, args.ready wait, args.timeout | | | if name == " main ": | | | main |