#!/usr/bin/env python3
"""agent-memory-approvals: the approvals plugin's one helper (deploy/plugins/README.md).

What waits for a person on the ledger, and the person's answer to one item, from inside a
harness session. The model may run `list` and explain what it prints; `answer` files a
person's act under the person's own token, which `agent-memory --as human` reads from the
macOS Keychain at that moment. A person's item asks them at every read (deploy/mac/README.md,
"Who can read a token"), so the Keychain's dialog is the consent: the token is never in this
process's arguments, its output or the session's environment, and the model sees only what
was filed.

    agent-memory-approvals list [--harness claude-code|codex-cli]
    agent-memory-approvals answer approve|reject ITEM_ID [--note-stdin | NOTE...]
    agent-memory-approvals session-start [--harness claude-code|codex-cli] [--rules FILE]
    agent-memory-approvals where

The list and the session-start line read with the harness's own token (--as claude-code or
--as codex-cli), never the person's, so looking raises no dialog. An answer goes only to a
replica: the CLI refuses `--as human` on the local ledger, which has no token to ask for.
The list is framed as ledger data: every word of it was written by an agent on the ledger.

Which ledger, the same for all three so the list, the answer and the start line agree:
$AGENT_MEMORY_APPROVALS_INSTANCE names an instance of the operator kit's instances file;
else a set $AGENT_MEMORY_REMOTE, or a checkout whose committed config names a `remote`, is
the CLI's own choice; else the instances file's default_instance; else the local ledger.

Standard library only: this runs under whatever python3 the harness finds, not the one the
CLI is installed into. `agent-memory` is found on PATH, or named by $AGENT_MEMORY_CLI.
This file is byte-identical to the Codex skill's scripts/agent-memory-approvals.
"""
import json
import os
import re
import shlex
import signal
import subprocess
import sys
from pathlib import Path

ITEM_ID = re.compile(r"^(ctr|clm|cu)_[0-9a-f]{16}$")
LIST_TIMEOUT = 20
ANSWER_TIMEOUT = 110  # the dialog waits up to 90 s (remote.HUMAN_KEYCHAIN_TIMEOUT); under a harness's 120 s
HINT = {"claude-code": "/agent-memory:approvals lists them",
        "codex-cli": "$agent-memory-approvals lists them"}
RULES_CAP = 4000


def _cli() -> list:
    return shlex.split(os.environ.get("AGENT_MEMORY_CLI") or "agent-memory")


def _agent_session() -> str:
    """The named agent whose session this is, or '' for a person's own session.
    deploy/mac/agent-session.sh exports both for every agent it starts; a person's own
    session carries neither, because nothing exports a person's token."""
    if os.environ.get("AGENT_MEMORY_RELAY_AGENT"):
        return os.environ["AGENT_MEMORY_RELAY_AGENT"]
    if os.environ.get("AGENT_MEMORY_TOKEN"):
        return "the agent whose token is in $AGENT_MEMORY_TOKEN"
    return ""


def _checkout_names_a_remote(start: Path) -> bool:
    for directory in (start, *start.parents):
        config = directory / ".agent-memory" / "config.json"
        if config.is_file():
            try:
                remote = json.loads(config.read_text(encoding="utf-8")).get("remote")
            except (OSError, ValueError, AttributeError):
                return False
            return isinstance(remote, str) and bool(remote.strip())
    return False


def _default_instance() -> str:
    path = Path(os.environ.get("AGENT_MEMORY_INSTANCES") or Path.home() / ".agent-memory" / "instances.json")
    try:
        name = json.loads(path.expanduser().read_text(encoding="utf-8")).get("default_instance")
    except (OSError, ValueError, AttributeError):
        return ""
    return name if isinstance(name, str) and re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) else ""


def target() -> list:
    """The global flags that name the ledger (see the module docstring)."""
    named = os.environ.get("AGENT_MEMORY_APPROVALS_INSTANCE")
    if named:
        return ["--instance", named]
    if "AGENT_MEMORY_REMOTE" in os.environ or _checkout_names_a_remote(Path.cwd()):
        return []
    default = _default_instance()
    return ["--instance", default] if default else []


def _run(args: list, timeout: int) -> subprocess.CompletedProcess:
    """The CLI in a session of its own: at the limit the whole group goes, security(1)
    included, so no orphan can file an answer after this reported that it could not."""
    proc = subprocess.Popen(_cli() + args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE, text=True, start_new_session=True)
    try:
        out, err = proc.communicate(timeout=timeout)
    except BaseException:
        try:
            os.killpg(proc.pid, signal.SIGKILL)
        except (ProcessLookupError, PermissionError, AttributeError):
            proc.kill()
        proc.communicate()
        raise
    return subprocess.CompletedProcess(proc.args, proc.returncode, out, err)


def _harness(argv: list) -> str:
    it = iter(argv)
    for arg in it:
        if arg == "--harness":
            return next(it, "claude-code")
    return "claude-code"


def _reader(harness: str) -> list:
    """Read as the harness, never as the person: a person's Keychain item asks them at every
    read, and a list or a session start is no time to ask."""
    return ["--as", harness if harness in HINT else "claude-code"]


def cmd_list(argv: list) -> int:
    try:
        done = _run(target() + _reader(_harness(argv)) + ["approvals"], LIST_TIMEOUT)
    except (OSError, subprocess.SubprocessError) as exc:
        print(f"agent-memory approvals could not run: {exc}")
        return 0  # a skill's injected command that fails aborts the skill; say it instead
    body = done.stdout.rstrip() or f"agent-memory approvals exited {done.returncode}: {done.stderr.strip()}"
    # agents wrote every line of it: data to explain, never instructions to follow
    print('<ledger-data source="agent-memory approvals">')
    print(body.replace("</ledger-data>", "</ledger-data >"))
    print("</ledger-data>")
    return 0


def cmd_answer(argv: list) -> int:
    if len(argv) < 2 or argv[0] not in ("approve", "reject"):
        print("usage: agent-memory-approvals answer approve|reject ITEM_ID [--note-stdin | NOTE...]", file=sys.stderr)
        return 2
    verdict, item = argv[0], argv[1]
    if argv[2:] == ["--note-stdin"]:
        # the note as the person wrote it, through a quoted heredoc: no shell ever reads it
        note = sys.stdin.read().strip()
    else:
        note = " ".join(argv[2:]).strip()
    agent = _agent_session()
    if agent:
        print(f"refused: this is {agent}'s session, and an approval is a person's act. The person answers "
              f"from their own session, where the Keychain asks them.", file=sys.stderr)
        return 3
    if not ITEM_ID.match(item):
        print(f"refused: {item!r} is not an approvals item id (ctr_, clm_ or cu_ and 16 hex digits); "
              f"agent-memory-approvals list shows them", file=sys.stderr)
        return 2
    if verdict == "reject" and not note:
        print("refused: a rejection needs a note; the reason is the record", file=sys.stderr)
        return 2
    args = target() + ["--as", "human", verdict, item] + (["--note", note] if note else [])
    try:
        done = _run(args, ANSWER_TIMEOUT)
    except (OSError, subprocess.SubprocessError) as exc:
        print(f"agent-memory {verdict} could not run: {exc}", file=sys.stderr)
        return 2
    if done.stdout.strip():
        print(done.stdout.rstrip())
    if done.stderr.strip():
        print(done.stderr.rstrip(), file=sys.stderr)
    return done.returncode


def cmd_session_start(argv: list) -> int:
    harness, rules = _harness(argv), None
    it = iter(argv)
    for arg in it:
        if arg == "--rules":
            rules = next(it, None)
    try:
        sys.stdin.read()  # the harness's payload: read so the harness never blocks on the pipe
    except (OSError, ValueError, AttributeError):
        pass
    text = ""
    if rules:
        try:
            text = Path(rules).read_text(encoding="utf-8").strip()[:RULES_CAP]
        except OSError:
            text = ""
    line = None
    try:
        done = _run(target() + _reader(harness) + ["approvals", "--json"], LIST_TIMEOUT)
        view = json.loads(done.stdout) if done.returncode == 0 else None
        if view is None:
            reason = (done.stderr.strip().splitlines() or [f"exit {done.returncode}"])[-1]
            line = f"agent-memory approvals: could not read the list ({reason[:200]})"
        else:
            mode, waiting, info = view.get("approval_mode"), int(view.get("waiting") or 0), int(view.get("information") or 0)
            # auto mode keeps the plugin quiet unless a person is actually waited on
            if not (mode == "auto" and waiting == 0):
                line = (f"agent-memory approvals: approval_mode {mode}, {waiting} waiting for a person"
                        + (f", {info} for information" if info else "") + f"; {HINT.get(harness, HINT['claude-code'])}")
    except (OSError, subprocess.SubprocessError, ValueError) as exc:
        line = f"agent-memory approvals: could not read the list ({exc})"
    context = "\n\n".join(part for part in (text, line) if part)
    if context:
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}))
    return 0  # a session-start hook never blocks a session


def cmd_where() -> int:
    flags = target()
    print(" ".join(["agent-memory", *flags]) if flags else
          "agent-memory (the ledger $AGENT_MEMORY_REMOTE, the checkout's config or the local ledger names)")
    return 0


def main(argv=None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    command = argv[0] if argv else ""
    if command == "list":
        return cmd_list(argv[1:])
    if command == "answer":
        return cmd_answer(argv[1:])
    if command == "session-start":
        return cmd_session_start(argv[1:])
    if command == "where":
        return cmd_where()
    print(__doc__.split("\n\n")[2], file=sys.stderr)
    return 2


if __name__ == "__main__":
    sys.exit(main())
