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

mobile_mode.py

view on github ↗229 lines · python
"""NAVI mobile mode — per-tab response mirroring + Telegram-driven turns.

No tmux. Each Claude Code tab is identified by its CLAUDE_CODE_SESSION_ID
(== the Stop hook's `session_id`). A tab opts in by running the /navi-mobile
skill (-> navi_tab.py on), which drops a per-session flag here. While the flag
is set, the Stop hook (navi_response_hook.py) forwards that tab's responses to
the single Telegram chat and BLOCKS waiting for the user's reply, which it feeds
back as the tab's next turn ({"decision":"block","reason": <reply>}).

State under ~/.navi/mobile:
  on_<sid>          -> flag file (presence = tab is on mobile); JSON {label,cwd,ts}
  reply_<sid>.txt   -> the bot writes the user's reply here; the hook consumes it
  msg_<message_id>.json -> {"sid": ...} so a Telegram *reply* maps to its tab
  last_session      -> most recently forwarded sid (for plain, non-reply texts)

Correlation mirrors the permission gate's req_id pattern, so one chat cleanly
spans many concurrent tabs: reply to a tab's message -> drives that exact tab.
"""
from __future__ import annotations

import os
import json
import time

NAVI_DIR = os.path.join(os.path.expanduser("~"), ".navi")
MOBILE_DIR = os.path.join(NAVI_DIR, "mobile")
EXIT_SENTINEL = "__EXIT__"


def _ensure() -> None:
    os.makedirs(MOBILE_DIR, mode=0o700, exist_ok=True)


def short(sid) -> str:
    return (sid or "")[:6] or "tab"


def _flag(sid):
    return os.path.join(MOBILE_DIR, f"on_{sid}")


def _reply(sid):
    return os.path.join(MOBILE_DIR, f"reply_{sid}.txt")


def _msg(mid):
    return os.path.join(MOBILE_DIR, f"msg_{mid}.json")


# ---- per-session mobile flag --------------------------------------------

def session_on(sid) -> bool:
    return bool(sid) and os.path.exists(_flag(sid))


def set_session(sid, on: bool, label: str = "", cwd: str = "") -> None:
    if not sid:
        return
    _ensure()
    if on:
        with open(_flag(sid), "w") as f:
            json.dump({"label": label, "cwd": cwd, "ts": time.time()}, f)
    else:
        try:
            os.unlink(_flag(sid))
        except OSError:
            pass


def session_info(sid) -> dict:
    try:
        with open(_flag(sid)) as f:
            return json.load(f)
    except (OSError, ValueError):
        return {}


def list_sessions() -> list:
    out = []
    try:
        for fn in os.listdir(MOBILE_DIR):
            if fn.startswith("on_"):
                sid = fn[3:]
                info = session_info(sid)
                out.append({"sid": sid, "label": info.get("label", ""), "cwd": info.get("cwd", "")})
    except OSError:
        pass
    return out


# ---- message <-> session mapping ----------------------------------------

def record_forward(message_id, sid) -> None:
    _ensure()
    with open(_msg(message_id), "w") as f:
        json.dump({"sid": sid, "ts": time.time()}, f)
    with open(os.path.join(MOBILE_DIR, "last_session"), "w") as f:
        f.write(sid)
    _prune()


def session_for_message(message_id):
    try:
        with open(_msg(message_id)) as f:
            return json.load(f).get("sid")
    except (OSError, ValueError):
        return None


def last_session():
    try:
        with open(os.path.join(MOBILE_DIR, "last_session")) as f:
            return f.read().strip() or None
    except OSError:
        return None


# ---- reply channel (bot writes, hook consumes) --------------------------

def write_reply(sid, text) -> None:
    _ensure()
    p = _reply(sid)
    tmp = p + ".tmp"
    with open(tmp, "w") as f:
        f.write(text)
    os.replace(tmp, p)


def take_reply(sid):
    """Read and delete the pending reply for a session (hook side)."""
    p = _reply(sid)
    try:
        with open(p) as f:
            t = f.read()
        os.unlink(p)
        return t
    except OSError:
        return None


def release(sid) -> None:
    """Phone side: unblock a waiting hook and turn the tab's flag off."""
    write_reply(sid, EXIT_SENTINEL)
    set_session(sid, False)


def _prune(max_age: int = 86400) -> None:
    try:
        now = time.time()
        for fn in os.listdir(MOBILE_DIR):
            if fn.startswith("msg_") and fn.endswith(".json"):
                fp = os.path.join(MOBILE_DIR, fn)
                try:
                    if now - os.path.getmtime(fp) > max_age:
                        os.unlink(fp)
                except OSError:
                    pass
    except OSError:
        pass


# ---- Telegram entry points (called from telegram_bot.py main loop) -------

def handle_mobile_command(text, chat_id, send_message) -> bool:
    """/tabs, /desktop [id], /mobile (help), /mode. Returns True if handled."""
    t = (text or "").strip()
    parts = t.split()
    cmd = parts[0].lower() if parts else ""
    if cmd not in ("/tabs", "/desktop", "/mobile", "/mode"):
        return False

    if cmd in ("/tabs", "/mode"):
        ss = list_sessions()
        if not ss:
            send_message(chat_id, "No tabs on mobile. Run /navi-mobile inside a Claude tab to enable one.")
            return True
        lines = ["\U0001f4f1 Tabs on mobile:"]
        for s in ss:
            lbl = s["label"] or os.path.basename(s["cwd"] or "")
            lines.append(f"• {short(s['sid'])}  {lbl}")
        lines.append("\nReply to a tab's message to drive it. /desktop <id> to release (or /desktop for all).")
        send_message(chat_id, "\n".join(lines))
        return True

    if cmd == "/mobile":
        send_message(chat_id,
                     "To put a tab on mobile, run /navi-mobile *inside that Claude tab*.\n"
                     "Then reply here to drive it. /tabs to list · /desktop to release.")
        return True

    if cmd == "/desktop":
        target = parts[1] if len(parts) > 1 else None
        ss = list_sessions()
        if not ss:
            send_message(chat_id, "No tabs on mobile.")
            return True
        released = []
        for s in ss:
            if target is None or s["sid"].startswith(target):
                release(s["sid"])
                released.append(short(s["sid"]))
        if released:
            send_message(chat_id, "\U0001f5a5 Released to keyboard: " + ", ".join(released))
        else:
            send_message(chat_id, f"No tab matching '{target}'.")
        return True

    return False


def handle_mobile_reply(msg, text, chat_id, send_message) -> bool:
    """Route a Telegram reply to its tab's reply channel. True if consumed."""
    if not text or text.startswith("/"):
        return False

    sid = None
    reply_to = (msg.get("reply_to_message") or {}).get("message_id")
    if reply_to is not None:
        sid = session_for_message(reply_to)
    if not sid:
        # plain text: route to the most-recent tab, only if some tab is on mobile
        if list_sessions():
            sid = last_session()
    if not sid or not session_on(sid):
        return False

    write_reply(sid, text)
    send_message(chat_id, f"➡️ tab {short(sid)}")
    return True
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