Download code/moth/integrations/claude_code.py from darioooooo0o/moth-112m: direct link, hf CLI and curl.
- Browser
- Download file 5.78 kB
-
https://huggingface.co/darioooooo0o/moth-112m/resolve/main/code/moth/integrations/claude_code.py
- Command line
-
hf download hf://darioooooo0o/moth-112m/code/moth/integrations/claude_code.py
-
curl -L -o claude_code.py https://huggingface.co/darioooooo0o/moth-112m/resolve/main/code/moth/integrations/claude_code.py
5.78 kB
| """Claude Code `Stop` hook: send Claude's final message of each turn to moth (one-way; moth never talks back). | |
| Install (see README.md): a `Stop` command hook in the foreground (no "async"), "timeout": 3. It posts before it exits: | |
| a one-shot `claude -p` kills a background ("async": true) hook when it exits, before the POST (seen live | |
| 2026-10-18), and the wait is short: moth only queues the text, and we give up on it after 1 s. | |
| Stop fires only for the main agent; subagents fire `SubagentStop` instead, which we don't hook. | |
| The text comes from `last_assistant_message` in the Stop input (docs: code.claude.com/docs/en/hooks#stop-input; | |
| the transcript "isn't guaranteed to include the final message at Stop time"). Only when an older Claude Code | |
| sends no such key do we read `transcript_path`, and then only the current turn, so an earlier turn's reply is | |
| never re-sent. The session is the Stop input's `session_id`, its label the basename of `cwd` (the project folder), | |
| so moth can tell several sessions apart. 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 _is_prompt(d: dict) -> bool: | |
| """A real user prompt (turn boundary), not a tool_result line, which the transcript also logs as "user".""" | |
| content = (d.get("message") or {}).get("content") | |
| if isinstance(content, str): | |
| return True | |
| return isinstance(content, list) and any(isinstance(b, dict) and b.get("type") != "tool_result" for b in content) | |
| def last_text(transcript: str) -> str: | |
| text = "" | |
| with open(os.path.expanduser(transcript), encoding="utf-8", errors="replace") as f: | |
| for line in f: | |
| try: | |
| d = json.loads(line) | |
| except ValueError: | |
| continue | |
| if not isinstance(d, dict) or d.get("isSidechain"): | |
| continue | |
| if d.get("type") == "user" and _is_prompt(d): | |
| text = "" # new turn: forget the previous turn's reply | |
| elif d.get("type") == "assistant": | |
| blocks = (d.get("message") or {}).get("content") or [] | |
| parts = [b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text"] | |
| if any(p.strip() for p in parts): | |
| text = "\n".join(parts) # keep the newest assistant text of this turn | |
| return text | |
| 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 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.""" | |
| 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 post(text: str, session=None, label=None) -> None: | |
| body = {"text": text, "source": "claude-code", "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()}) | |
| def main() -> None: | |
| try: | |
| hook = json.load(sys.stdin.buffer) # bytes: json detects UTF-8 (the locale may be cp1252 on Windows) | |
| if "last_assistant_message" in hook: | |
| text = hook["last_assistant_message"] or "" | |
| else: | |
| text = last_text(hook["transcript_path"]) | |
| if text.strip(): | |
| session = hook.get("session_id") if isinstance(hook.get("session_id"), str) else None | |
| post(text, session, label_of(hook.get("cwd"), session)) | |
| except BaseException: # noqa: BLE001 moth down, timeout, odd input: never bother Claude | |
| pass | |
| if __name__ == "__main__": | |
| main() | |
| sys.exit(0) | |