File size: 5,540 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
"""Codex CLI adapter: send Codex's final message of each turn to moth (one-way; moth never talks back).

Two ways in; install ONE of them (both would post every reply twice). See README.md.
  Stop hook (preferred): ~/.codex/hooks.json, "Stop" event, JSON on stdin with `last_assistant_message`. Run it in
      the foreground (no "async"), "timeout": 3: `codex exec` drops a background hook when it exits, before the POST
      (seen live 2026-10-18; the interactive TUI posted either way). We post before exiting, giving up after 1 s.
      Codex runs a hooks.json hook only once it is approved in the interactive `codex` (/hooks), and again after
      every edit of the file (the trust is a hash of the entry); unapproved, `codex exec` skips it silently.
      Codex runs user Stop hooks for root turns only: thread-spawned subagents fire `SubagentStop`, internal
      subagents and memory consolidation skip user hooks (codex-rs/core/src/hook_runtime.rs run_turn_stop_hooks,
      codex-rs/hooks/src/events/stop.rs select_handlers; rust-v0.163.0).
  notify (legacy): `notify = [...]` in ~/.codex/config.toml. Codex appends one JSON argument
      {"type": "agent-turn-complete", ..., "last-assistant-message": ...} (codex-rs/hooks/src/legacy_notify.rs)
      and spawns us without waiting. It is not gated, so subagent turns post too.

Session and label, so moth can tell several sessions apart: the hook's `session_id` / notify's `thread-id`, and the
basename of `cwd` (the project folder), present in both (codex-rs/hooks/src/events/stop.rs StopRequest,
legacy_notify.rs). Prints nothing, always exits 0, gives up after 1 s. MOTH_URL overrides http://127.0.0.1:4747; the token comes from
~/.moth/token (MOTH_TOKEN_FILE overrides).
"""
import json
import os
import sys

URL = os.environ.get("MOTH_URL", "http://127.0.0.1:4747").rstrip("/") + "/api/agent"
TOKEN_FILE = os.environ.get("MOTH_TOKEN_FILE") or os.path.join(os.path.expanduser("~"), ".moth", "token")


def _token() -> str:
    """moth's per-install token (made by moth.serve on first run); read on every post, it may be newer than us."""
    try:
        with open(TOKEN_FILE) as f:
            return f.read().strip()
    except OSError:
        return ""


def label_of(cwd, session) -> str:
    """The project folder's name; without a usable cwd, a short session id."""
    name = cwd.rstrip("/\\").replace("\\", "/").rsplit("/", 1)[-1] if isinstance(cwd, str) else ""
    return name or (session or "")[:8]


def final_turn(argv: list[str], stdin) -> tuple[str, str | None, str]:
    """(final text, session id, label)."""
    if len(argv) > 1 and argv[-1].lstrip().startswith("{"):        # notify: payload is the last argument
        d = json.loads(argv[-1])
        text = (d.get("last-assistant-message") or "") if d.get("type") == "agent-turn-complete" else ""
        session = d.get("thread-id")
    else:
        d = json.load(stdin)                                         # hook: payload on stdin
        text = (d.get("last_assistant_message") or "") if d.get("hook_event_name", "Stop") == "Stop" else ""
        session = d.get("session_id")
    session = session if isinstance(session, str) else None
    return text, session, label_of(d.get("cwd"), session)


def http_post(url: str, data: bytes, headers: dict, timeout: float = 1.0) -> None:
    """POST and wait for moth's answer (never via a proxy: moth is local). Plain http:// goes over a bare socket:
    this runs on every agent turn, and importing urllib.request alone takes longer than the whole post.
    (The same function as claude_code.py's: each adapter is one standalone file.)"""
    if not url.lower().startswith("http://"):   # https (a moth behind something): the standard client
        import urllib.request
        req = urllib.request.Request(url, data=data, headers=headers)
        urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=timeout).close()
        return
    import socket
    import time
    netloc, _, path = url[7:].partition("/")
    host, _, port = netloc.rpartition(":") if netloc.rpartition(":")[2].isdigit() else (netloc, "", "80")
    if any("\r" in str(v) or "\n" in str(v) for v in headers.values()):
        raise ValueError("bad header")   # a token with a line break in it: refused, as http.client does
    head = "".join(f"{k}: {v}\r\n" for k, v in {**headers, "Content-Length": len(data)}.items())
    req = f"POST /{path} HTTP/1.1\r\nHost: {netloc}\r\n{head}Connection: close\r\n\r\n".encode("latin-1") + data
    with socket.create_connection((host.strip("[]"), int(port)), timeout=timeout) as s:
        end = time.monotonic() + timeout   # as urllib: up to `timeout` to connect, then up to `timeout` for the answer
        s.sendall(req)
        while True:   # moth's answer, to the end (it has the turn before we go)
            s.settimeout(max(0.001, end - time.monotonic()))
            if not s.recv(4096):
                break


def main() -> None:
    try:
        text, session, label = final_turn(sys.argv, sys.stdin.buffer)   # bytes: UTF-8 whatever the locale
        if text.strip():
            body = {"text": text, "source": "codex", "session": session, "label": label}
            http_post(URL, json.dumps({k: v for k, v in body.items() if v}).encode(),
                      {"Content-Type": "application/json", "X-Moth-Token": _token()})
    except BaseException:  # noqa: BLE001  moth down, timeout, odd input: never bother Codex
        pass


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