PRODUCTAGENTharrisonhjohnson/productagent · /harnesses/todo/bot/navi_response_hook.py
000%

navi_response_hook.py

view on github ↗202 lines · python
#!/usr/bin/env python3
"""Stop hook — NAVI mobile mode (no tmux).

Fires when a Claude Code tab finishes a turn. If THIS tab is on mobile
(a ~/.navi/mobile/on_<session_id> flag exists), it:
  1. forwards the final assistant message to the single Telegram chat,
  2. records message_id -> session_id (so a Telegram reply maps back here),
  3. BLOCKS, polling for the user's reply, then feeds it back as the next turn
     via {"decision":"block","reason": <reply>} on stdout.

Reply of __EXIT__ / "/desktop" / "stop" (or timeout) hands the tab back to the
keyboard (exit 0, no block). Desktop tabs are a fast no-op. Fails silent.

Timeout is NAVI_MOBILE_TIMEOUT seconds (default 600); if Claude Code kills the
hook sooner, the turn simply returns to the keyboard (safe).
"""
import sys
import os
import json
import time
import urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
NAVI_ENV = os.path.join(HERE, ".env")
POLL_TIMEOUT = int(os.environ.get("NAVI_MOBILE_TIMEOUT", "600"))
EXIT_WORDS = {"__EXIT__", "/desktop", "desktop", "stop", "exit"}


def load_env():
    if not os.path.exists(NAVI_ENV):
        return
    for line in open(NAVI_ENV):
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        k = k.strip()
        v = v.strip().strip('"').strip("'")
        if k and k not in os.environ:
            os.environ[k] = v


def last_assistant_text(transcript_path):
    try:
        lines = open(transcript_path, encoding="utf-8").read().splitlines()
    except OSError:
        return ""
    text = ""
    for ln in lines:
        try:
            o = json.loads(ln)
        except Exception:
            continue
        if o.get("type") != "assistant" and o.get("role") != "assistant":
            continue
        content = (o.get("message") or {}).get("content")
        blocks = []
        if isinstance(content, list):
            for b in content:
                if isinstance(b, dict) and b.get("type") == "text" and b.get("text"):
                    blocks.append(b["text"])
        elif isinstance(content, str):
            blocks.append(content)
        if blocks:
            text = "\n".join(blocks)
    return text.strip()


def chunk_text(text, limit=3500, max_chunks=8):
    """Split on paragraph, then line, then hard boundaries. Telegram caps a
    message at 4096 chars; we leave headroom for the header and part marker."""
    if len(text) <= limit:
        return [text]
    chunks, buf = [], ""

    def flush():
        nonlocal buf
        if buf:
            chunks.append(buf)
            buf = ""

    for para in text.split("\n\n"):
        if len(para) > limit:
            flush()
            for line in para.split("\n"):
                while len(line) > limit:
                    chunks.append(line[:limit])
                    line = line[limit:]
                if not buf:
                    buf = line
                elif len(buf) + 1 + len(line) <= limit:
                    buf += "\n" + line
                else:
                    flush()
                    buf = line
            continue
        if not buf:
            buf = para
        elif len(buf) + 2 + len(para) <= limit:
            buf += "\n\n" + para
        else:
            flush()
            buf = para
    flush()

    if len(chunks) > max_chunks:
        dropped = len(chunks) - max_chunks
        chunks = chunks[:max_chunks]
        chunks[-1] += (
            "\n\n…(%d more part%s suppressed — read the full reply on desktop)"
            % (dropped, "" if dropped == 1 else "s")
        )
    return chunks


def send_telegram(token, chat_id, text):
    data = json.dumps({"chat_id": int(chat_id), "text": text}).encode()
    req = urllib.request.Request(
        f"https://api.telegram.org/bot{token}/sendMessage",
        data=data, headers={"Content-Type": "application/json"},
    )
    try:
        resp = urllib.request.urlopen(req, timeout=10)
        return json.loads(resp.read().decode()).get("result", {}).get("message_id")
    except Exception:
        return None


def main():
    try:
        hook = json.load(sys.stdin)
    except Exception:
        return

    sid = hook.get("session_id") or os.environ.get("CLAUDE_CODE_SESSION_ID")

    try:
        import mobile_mode
    except Exception:
        return
    if not sid or not mobile_mode.session_on(sid):
        return  # desktop tab -> no-op

    load_env()
    token = os.environ.get("TELEGRAM_BOT_TOKEN")
    chat_id = os.environ.get("TELEGRAM_USER_ID")
    if not token or not chat_id:
        return

    resp = last_assistant_text(hook.get("transcript_path") or "")
    if not resp:
        return  # tool-only turn; nothing to mirror, don't block

    cwd = hook.get("cwd") or os.getcwd()
    project = os.path.basename(cwd) or "session"
    sh = mobile_mode.short(sid)
    parts = chunk_text(resp)
    total = len(parts)
    for i, part in enumerate(parts, 1):
        marker = f" ({i}/{total})" if total > 1 else ""
        mid = send_telegram(
            token, chat_id, f"\U0001f4f1 {project} · {sh}{marker}\n\n{part}"
        )
        # Map every part back to this session, so replying to any of them lands here.
        if mid:
            try:
                mobile_mode.record_forward(mid, sid)
            except Exception:
                pass

    # Block until the user replies from Telegram (or times out).
    deadline = time.time() + POLL_TIMEOUT
    while time.time() < deadline:
        reply = mobile_mode.take_reply(sid)
        if reply is not None:
            r = reply.strip()
            if r in EXIT_WORDS or r.lower() in EXIT_WORDS:
                mobile_mode.set_session(sid, False)
                send_telegram(token, chat_id, f"\U0001f5a5 tab {sh} released to keyboard.")
                return  # exit 0 -> back to keyboard
            # Feed the reply back as this tab's next turn.
            print(json.dumps({
                "decision": "block",
                "reason": reply,
                "systemMessage": f"\U0001f4f1 via Telegram → {sh}",
            }))
            return
        time.sleep(2)

    # Timed out waiting for a reply.
    mobile_mode.set_session(sid, False)
    send_telegram(token, chat_id,
                  f"⏳ tab {sh} idle {POLL_TIMEOUT // 60}m — released to keyboard. /navi-mobile to resume.")


if __name__ == "__main__":
    try:
        main()
    except Exception:
        pass
    sys.exit(0)
flow-designglobal
SKILL.md74 lines
SKILL.md162 lines
pm-strategistglobal
pm-strategist.md51 lines
todosystem
bot31 items
agent_executor.py344 lines
behavior_state.py106 lines
BOT.md175 lines
config.py76 lines
git_sync.py87 lines
hyrule_server.py284 lines
manage_bot.sh47 lines
mcp_server.py324 lines
mobile_mode.py229 lines
navi_tab.py95 lines
prework_engine.py426 lines
run_bot.sh18 lines
run_hyrule.sh38 lines
scheduler.py958 lines
session.py264 lines
telegram_bot.py3328 lines
todo_manager.py1130 lines
ventures_signal.py314 lines
voice_handler.py203 lines
VOICE_SETUP.md112 lines
warroom_manager.py1004 lines
README.md44 lines
night-orderssystem
README.md84 lines
loops-spec.md212 lines
settings.json116 lines
README.md59 lines