#!/usr/bin/env python3
"""
AgentsRoom hook for Claude Code — and for Grok Build, which loads the exact
same `.claude/settings.local.json` entries through its Claude Code
compatibility layer (project scope, folder-trust gated, anchored on the git
root; AgentsRoom launches `grok` with `--trust` so they actually load).

Wired into `.claude/settings.local.json` for two families of events:

  1. PostToolUse (Edit/Write/MultiEdit/NotebookEdit matcher): Claude pipes a
     JSON payload on stdin after every file edit; we forward it to the
     AgentsRoom desktop app (`/file-event`) so it can attribute the modified
     file to the right agent — regardless of Claude session rotations
     (/clear, /compact, /resume).

  2. Status lifecycle events (UserPromptSubmit, PermissionRequest,
     Notification, Stop, StopFailure, SessionEnd): forwarded to
     `/status-event` so the desktop tracks the agent status (thinking /
     needs_input / done) DETERMINISTICALLY. This is the authoritative layer:
     the historical PTY-scraping heuristics stay as fallback, but they break
     whenever the CLI renders through the terminal's alternate screen buffer
     (`/tui fullscreen`), while hooks fire identically under every renderer.

Hard requirements:
  1. NEVER raise. Claude ignores our exit code but a crash still shows up in
     the user's terminal as garbage output. Every failure path is swallowed.
  2. NEVER block. We post with a short timeout and return; Claude treats this
     hook as async so it doesn't wait for us anyway, but we stay defensive.
  3. NO third-party deps. Only Python stdlib — this script ships as-is and
     runs on whatever `python3` the user already has on their PATH.

Discovery:
  - Endpoint (port + auth token) is read from <agentsroom-home>/hook-endpoint.json,
    rewritten by AgentsRoom on every boot. This makes the hook survive app
    restarts on long-running agents — env-var-baked ports would go stale.
  - Agent id is read from the AGENTSROOM_AGENT_ID env var, injected by
    AgentsRoom at PTY spawn time. Absent var = bail (user invoked `claude`
    outside AgentsRoom in a cwd that still has the hook installed).
  - Project id (optional) from AGENTSROOM_PROJECT_ID, also injected at PTY
    spawn time. Disambiguates the same agentId present in several projects
    (cloned repos commit agents.json). Older PTYs may not have it: the
    desktop falls back to a store scan.
  - AgentsRoom home directory: read from the AGENTSROOM_HOME env var (also
    injected at PTY spawn time). This selects between the prod sandbox
    (~/.agentsroom/) and the dev sandbox (~/.agentsroom-dev/) so dev-mode
    file events reach the dev desktop, not the prod one. Falls back to
    ~/.agentsroom for any caller that didn't set the var.
"""

import json
import os
import sys
import time
import urllib.request
import urllib.error

HOOK_TIMEOUT_SECONDS = 2.0
# Endpoint resolution retry: AgentsRoom removes hook-endpoint.json on quit and
# rewrites it on the next boot, and dev-mode reloads briefly recreate the
# server. Without a retry, every hook firing during that 200-1500ms window
# silently drops the event. Three short attempts cover restarts while staying
# well under the async hook's lifetime budget.
ENDPOINT_RETRY_ATTEMPTS = 3
ENDPOINT_RETRY_DELAY_SECONDS = 0.4
AGENTSROOM_DIR = os.environ.get('AGENTSROOM_HOME') or os.path.expanduser('~/.agentsroom')
ENDPOINT_FILENAME = os.path.join(AGENTSROOM_DIR, 'hook-endpoint.json')
LOG_FILENAME = os.path.join(AGENTSROOM_DIR, 'hook.log')
# Rotate the log at 512KB — large enough to keep a full debugging session,
# small enough that disk growth is bounded even if the hook fires thousands
# of times. We truncate on overflow rather than keeping N backups: this is
# a diagnostic buffer, not an audit trail.
LOG_MAX_BYTES = 512 * 1024

# Lifecycle events forwarded to /status-event. Anything else on stdin is
# treated as the legacy PostToolUse file-event payload. Keep this set in
# sync with STATUS_HOOK_EVENTS in electron/hookInstaller.ts.
STATUS_EVENTS = {
    'UserPromptSubmit',
    'PermissionRequest',
    'Notification',
    'Stop',
    'StopFailure',
    'SessionEnd',
}

# Grok Build emits the SAME lifecycle events but snake_cases the event VALUE
# ("user_prompt_submit" where Claude sends "UserPromptSubmit") and camelCases
# the payload KEYS ("hookEventName", "sessionId", "toolInput" where Claude
# sends "hook_event_name", "session_id", "tool_input"). Verified on grok
# 0.2.114 by capturing real hook payloads. `pick()` + this alias table
# normalize both dialects to the canonical Claude names before forwarding, so
# the desktop-side mapping stays single-sourced.
SNAKE_EVENT_ALIASES = {
    'user_prompt_submit': 'UserPromptSubmit',
    'permission_request': 'PermissionRequest',
    'notification': 'Notification',
    'stop': 'Stop',
    'stop_failure': 'StopFailure',
    'session_end': 'SessionEnd',
}


def pick(payload: dict, *keys: str):
    """First non-None value among `keys` — bridges Claude's snake_case and
    Grok's camelCase payload dialects without duplicating any handler."""
    for key in keys:
        value = payload.get(key)
        if value is not None:
            return value
    return None


def log(stage: str, **fields) -> None:
    """Best-effort diagnostic line. Never raises — a logging failure must
    not break the hook (which would let a silent disk-full condition surface
    as missing file attribution in the Changes panel).
    """
    try:
        os.makedirs(AGENTSROOM_DIR, exist_ok=True)
        try:
            if os.path.getsize(LOG_FILENAME) > LOG_MAX_BYTES:
                # Truncate rather than rotate: keeps implementation tiny and
                # avoids any lock coordination with a running AgentsRoom.
                open(LOG_FILENAME, 'w').close()
        except OSError:
            pass
        ts = time.strftime('%Y-%m-%dT%H:%M:%S')
        parts = [ts, stage]
        for k, v in fields.items():
            parts.append(f'{k}={v!r}')
        with open(LOG_FILENAME, 'a', encoding='utf-8') as f:
            f.write(' '.join(parts) + '\n')
    except Exception:
        pass


def read_endpoint(agent_id: str, ctx: str):
    """Resolve the desktop listener (port + token) from the discovery file.
    Returns (port, token) or None. Retries briefly across app restarts."""
    endpoint = None
    last_err = None
    for attempt in range(ENDPOINT_RETRY_ATTEMPTS):
        try:
            with open(ENDPOINT_FILENAME, 'r', encoding='utf-8') as f:
                endpoint = json.load(f)
            break
        except FileNotFoundError as e:
            last_err = e
            if attempt < ENDPOINT_RETRY_ATTEMPTS - 1:
                time.sleep(ENDPOINT_RETRY_DELAY_SECONDS)
                continue
        except Exception as e:
            # Malformed JSON or permission issue — no point retrying.
            log('endpoint-read-failed', agent=agent_id, ctx=ctx, err=str(e))
            return None
    if endpoint is None:
        log('endpoint-read-failed',
            agent=agent_id, ctx=ctx,
            err=str(last_err), attempts=ENDPOINT_RETRY_ATTEMPTS)
        return None

    port = endpoint.get('port')
    token = endpoint.get('token')
    if not port or not token:
        log('endpoint-malformed', agent=agent_id, ctx=ctx, port=port, has_token=bool(token))
        return None
    return port, token


def post_json(route: str, body: dict, agent_id: str, ctx: str) -> None:
    """POST `body` to the desktop listener. Fire-and-forget with a short
    timeout; every failure path is logged and swallowed."""
    resolved = read_endpoint(agent_id, ctx)
    if resolved is None:
        return
    port, token = resolved

    req = urllib.request.Request(
        f'http://127.0.0.1:{port}{route}',
        data=json.dumps(body).encode('utf-8'),
        method='POST',
        headers={
            'Content-Type': 'application/json',
            'X-AgentsRoom-Token': token,
        },
    )

    try:
        resp = urllib.request.urlopen(req, timeout=HOOK_TIMEOUT_SECONDS)
        status = resp.getcode()
        resp.close()
        log('posted', agent=agent_id, ctx=ctx, route=route, port=port, status=status)
    except urllib.error.HTTPError as e:
        # 401 = stale token, 413 = body too big, 404 = wrong route, etc.
        log('http-error', agent=agent_id, ctx=ctx, route=route, port=port, code=e.code)
    except (urllib.error.URLError, OSError) as e:
        # App not running, port reclaimed, network stack hiccup — all benign
        # at runtime but worth logging so we know why the event went dark.
        log('post-failed', agent=agent_id, ctx=ctx, route=route, port=port, err=str(e))


def handle_status_event(agent_id: str, hook_event: str, payload: dict) -> None:
    """Forward a lifecycle event to /status-event. The script stays dumb on
    purpose: it forwards the discriminating fields verbatim and the desktop
    decides the status transition (easier to iterate + unit-test app-side)."""
    # Grok fires an EXTRA, observe-only Stop at session end (reason
    # "channel_closed" / "shutdown"); only reason == "end_turn" is a genuine
    # turn completion. Forwarding the extra one would run the full
    # end-of-turn pipeline (notification, unread) on a mere quit — SessionEnd
    # already covers that cleanup. Claude Code sends no `reason` field on
    # Stop: absent means forward, so this stays backward compatible.
    if hook_event == 'Stop':
        reason = payload.get('reason')
        if reason and reason != 'end_turn':
            return

    body = {
        'agentId': agent_id,
        # Optional spawn-time project id — disambiguates duplicated agentIds.
        'projectId': os.environ.get('AGENTSROOM_PROJECT_ID'),
        'hookEvent': hook_event,
        # Notification only: which notification fired (permission_prompt,
        # idle_prompt, ...). Older CLIs omit it; the desktop then falls back
        # to matching `message` (CLI chrome, English by construction).
        'notificationType': pick(payload, 'notification_type', 'notificationType'),
        'message': payload.get('message'),
        'claudeSessionId': pick(payload, 'session_id', 'sessionId'),
        'cwd': payload.get('cwd'),
        # `agent_id` in the hook payload is only present when a CLAUDE hook
        # fired INSIDE a subagent (Task tool). A subagent finishing its turn
        # must not mark the MAIN agent done — the desktop skips turn events
        # with this flag set. Deliberately NOT reading a camelCase variant:
        # Grok scopes subagent turns to its own SubagentStop event (which we
        # don't register), so its main-agent events never need the flag.
        'inSubagent': bool(payload.get('agent_id')),
    }
    post_json('/status-event', body, agent_id, ctx=hook_event)


def handle_file_event(agent_id: str, payload: dict) -> None:
    """Legacy PostToolUse path: forward the modified file to /file-event."""
    tool_name = pick(payload, 'tool_name', 'toolName')
    tool_input = pick(payload, 'tool_input', 'toolInput') or {}
    # Edit/Write/MultiEdit use `file_path`; NotebookEdit uses `notebook_path`.
    # Grok's `search_replace` (its Edit/Write alias target) also sends
    # `file_path` inside the camelCase `toolInput` envelope (verified 0.2.114).
    file_path = tool_input.get('file_path') or tool_input.get('notebook_path')
    if not tool_name or not file_path:
        # Not an edit we care about (Bash, Read, etc.). Don't log — too noisy.
        return

    post_json('/file-event', {
        'agentId': agent_id,
        'tool': tool_name,
        'filePath': file_path,
        'cwd': payload.get('cwd'),
    }, agent_id, ctx=str(tool_name))


def main() -> None:
    agent_id = os.environ.get('AGENTSROOM_AGENT_ID')
    if not agent_id:
        # Not an AgentsRoom-spawned process; nothing to do. Don't log — this
        # fires for every `claude` invocation outside AgentsRoom and would
        # bloat the log with noise for users who run the CLI directly.
        return

    try:
        raw = sys.stdin.read()
    except Exception as e:
        log('stdin-read-failed', agent=agent_id, err=str(e))
        return
    if not raw:
        log('empty-stdin', agent=agent_id)
        return

    try:
        payload = json.loads(raw)
    except Exception as e:
        log('bad-json', agent=agent_id, err=str(e), raw_len=len(raw))
        return

    hook_event = pick(payload, 'hook_event_name', 'hookEventName') or ''
    # Normalize Grok's snake_case event values to the canonical Claude names.
    hook_event = SNAKE_EVENT_ALIASES.get(hook_event, hook_event)
    if hook_event in STATUS_EVENTS:
        handle_status_event(agent_id, hook_event, payload)
    else:
        handle_file_event(agent_id, payload)


if __name__ == '__main__':
    try:
        main()
    except BaseException:
        # Last-ditch safety net. Never let anything reach Claude's terminal.
        pass
