{"slug": "claude-i", "title": "claude-i", "summary": "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.", "body_md": "| #!/usr/bin/env python3 | |\n| \"\"\"claude-i: like `claude -p`, but driven through an interactive Claude session. | |\n| Architecture: | |\n| - tmux session hosts an interactive `claude` (kill-session nukes the whole | |\n| process tree — pty alone can't do this reliably). | |\n| - A Stop hook (one-time install into ~/.claude/settings.json), gated on | |\n| $CLAUDE_I_SENTINEL, writes the hook payload and touches a sentinel. | |\n| - We wait on the sentinel, read the transcript path from the payload, | |\n| extract the last assistant message, then kill the tmux session. | |\n| Run with --verbose to tail the tmux pane while it runs (debug hangs). | |\n| \"\"\" | |\n| import argparse, json, os, shlex, subprocess, sys, tempfile, threading, time | |\n| from pathlib import Path | |\n| HOOK_CMD = ( | |\n| 'if [ -n \"$CLAUDE_I_SENTINEL\" ]; then ' | |\n| 'cat > \"$CLAUDE_I_SENTINEL.json\"; ' | |\n| 'touch \"$CLAUDE_I_SENTINEL\"; ' | |\n| 'fi' | |\n| ) | |\n| SETTINGS = Path.home() / \".claude\" / \"settings.json\" | |\n| def hook_installed() -> bool: | |\n| if not SETTINGS.exists(): | |\n| return False | |\n| try: | |\n| cfg = json.loads(SETTINGS.read_text()) | |\n| except json.JSONDecodeError: | |\n| return False | |\n| return any( | |\n| h.get(\"command\") == HOOK_CMD | |\n| for g in cfg.get(\"hooks\", {}).get(\"Stop\", []) | |\n| for h in g.get(\"hooks\", []) | |\n| ) | |\n| def install_hook() -> None: | |\n| SETTINGS.parent.mkdir(parents=True, exist_ok=True) | |\n| cfg = {} | |\n| if SETTINGS.exists(): | |\n| try: | |\n| cfg = json.loads(SETTINGS.read_text()) | |\n| except json.JSONDecodeError: | |\n| sys.exit(f\"{SETTINGS} is not valid JSON; refusing to touch it\") | |\n| cfg.setdefault(\"hooks\", {}).setdefault(\"Stop\", []).append( | |\n| {\"hooks\": [{\"type\": \"command\", \"command\": HOOK_CMD}]} | |\n| ) | |\n| SETTINGS.write_text(json.dumps(cfg, indent=2)) | |\n| def ensure_hook() -> None: | |\n| if hook_installed(): | |\n| return | |\n| print(f\"claude-i needs a Stop hook in {SETTINGS}.\", file=sys.stderr) | |\n| print(\"Gated on $CLAUDE_I_SENTINEL, so it won't affect normal Claude use.\", file=sys.stderr) | |\n| print(f\" command: {HOOK_CMD}\", file=sys.stderr) | |\n| if input(\"Install it now? [y/N] \").strip().lower() != \"y\": | |\n| sys.exit(\"aborted\") | |\n| install_hook() | |\n| print(\"Installed. Active on the next Claude session.\", file=sys.stderr) | |\n| print(\"If the first run hangs, run `claude` interactively once, type /hooks,\", file=sys.stderr) | |\n| print(\"acknowledge the change, then exit and retry.\", file=sys.stderr) | |\n| def tmux(*args: str, check: bool = True) -> subprocess.CompletedProcess: | |\n| return subprocess.run([\"tmux\", *args], capture_output=True, text=True, check=check) | |\n| def tail_pane(session: str, stop_event: threading.Event) -> None: | |\n| \"\"\"Stream tmux pane content to stderr until stop_event is set.\"\"\" | |\n| last = \"\" | |\n| while not stop_event.is_set(): | |\n| try: | |\n| out = tmux(\"capture-pane\", \"-pt\", session, check=False).stdout | |\n| except Exception: | |\n| break | |\n| if out != last: | |\n| sys.stderr.write(\"\\033[2J\\033[H\") # clear + reprint pane snapshot | |\n| sys.stderr.write(out) | |\n| sys.stderr.flush() | |\n| last = out | |\n| time.sleep(0.3) | |\n| def run(prompt: str, extra_args: list[str], verbose: bool, | |\n| ready_wait: float, timeout: int) -> str: | |\n| sentinel = Path(tempfile.mktemp(prefix=\"claude-i-\", suffix=\".done\")) | |\n| payload = Path(str(sentinel) + \".json\") | |\n| session = f\"claude-i-{os.getpid()}\" | |\n| # Build the claude command for `sh -c`. shlex.quote is essential — naive | |\n| # interpolation breaks on prompts containing spaces or shell metachars. | |\n| parts = [f\"CLAUDE_I_SENTINEL={shlex.quote(str(sentinel))}\", \"exec\", \"claude\"] | |\n| parts.extend(shlex.quote(a) for a in extra_args) | |\n| claude_cmd = \" \".join(parts) | |\n| tmux(\"new-session\", \"-d\", \"-s\", session, \"-x\", \"220\", \"-y\", \"50\", | |\n| \"sh\", \"-c\", claude_cmd) | |\n| tail_stop = threading.Event() | |\n| tail_thread = None | |\n| if verbose: | |\n| tail_thread = threading.Thread(target=tail_pane, args=(session, tail_stop), daemon=True) | |\n| tail_thread.start() | |\n| try: | |\n| # Let the TUI come up. | |\n| time.sleep(ready_wait) | |\n| # Paste the prompt (multiline-safe) and submit. | |\n| tmux(\"set-buffer\", \"-b\", session, prompt) | |\n| tmux(\"paste-buffer\", \"-t\", session, \"-b\", session) | |\n| tmux(\"send-keys\", \"-t\", session, \"Enter\") | |\n| # Wait for Stop hook. | |\n| deadline = time.time() + timeout | |\n| while not sentinel.exists(): | |\n| if time.time() > deadline: | |\n| raise TimeoutError( | |\n| f\"No Stop hook signal after {timeout}s. Likely causes:\\n\" | |\n| f\" - Hook not yet active (run `claude` once, /hooks, acknowledge)\\n\" | |\n| f\" - TUI never received the prompt (try --ready-wait 8)\\n\" | |\n| f\" - Re-run with --verbose to watch the tmux pane\" | |\n| ) | |\n| time.sleep(0.3) | |\n| # Parse hook payload → transcript → last assistant text. | |\n| if not payload.exists(): | |\n| return \"(hook fired but no payload written)\" | |\n| hook_input = json.loads(payload.read_text()) | |\n| transcript = Path(hook_input.get(\"transcript_path\", \"\")) | |\n| if not transcript.exists(): | |\n| return f\"(transcript missing: {transcript})\" | |\n| last = None | |\n| for line in transcript.read_text().splitlines(): | |\n| try: | |\n| msg = json.loads(line) | |\n| except json.JSONDecodeError: | |\n| continue | |\n| if msg.get(\"message\", {}).get(\"role\") == \"assistant\": | |\n| last = msg[\"message\"] | |\n| if not last: | |\n| return \"\" | |\n| return \"\".join( | |\n| b.get(\"text\", \"\") for b in last.get(\"content\", []) | |\n| if b.get(\"type\") == \"text\" | |\n| ) | |\n| finally: | |\n| tail_stop.set() | |\n| if tail_thread: | |\n| tail_thread.join(timeout=1) | |\n| # The whole point: kill-session reaps the entire process tree. | |\n| tmux(\"kill-session\", \"-t\", session, check=False) | |\n| for p in (sentinel, payload): | |\n| try: p.unlink() | |\n| except FileNotFoundError: pass | |\n| def main() -> None: | |\n| ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |\n| ap.add_argument(\"prompt\") | |\n| ap.add_argument(\"-v\", \"--verbose\", action=\"store_true\", | |\n| help=\"tail the tmux pane to stderr (debug hangs)\") | |\n| ap.add_argument(\"--ready-wait\", type=float, default=4.0, | |\n| help=\"seconds to let the TUI start before sending prompt\") | |\n| ap.add_argument(\"--timeout\", type=int, default=600, | |\n| help=\"seconds to wait for Stop hook before failing\") | |\n| ap.add_argument(\"extra\", nargs=argparse.REMAINDER, | |\n| help=\"extra args forwarded to claude\") | |\n| args = ap.parse_args() | |\n| ensure_hook() | |\n| print(run(args.prompt, args.extra, args.verbose, args.ready_wait, args.timeout)) | |\n| if __name__ == \"__main__\": | |\n| main() |", "url": "https://wpnews.pro/news/claude-i", "canonical_source": "https://gist.github.com/isingh/62bdfd0886b0b72bf6231c44f0389ecc", "published_at": "2026-05-15 03:57:39+00:00", "updated_at": "2026-06-03 00:43:36.254100+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure"], "entities": ["Claude", "tmux"], "alternates": {"html": "https://wpnews.pro/news/claude-i", "markdown": "https://wpnews.pro/news/claude-i.md", "text": "https://wpnews.pro/news/claude-i.txt", "jsonld": "https://wpnews.pro/news/claude-i.jsonld"}}