darioooooo0o's picture
moth 0.3.0
5cf6791 verified
|
Raw History Blame Contribute Delete
44.8 kB

moth harness adapters

Each adapter sends the coding agent's final reply of a turn to the running moth server, one way: POST $MOTH_URL/api/agent with {"text": "<raw markdown>", "source": "claude-code", "session": "<id>", "label": "moth-repo"}. source is the harness name; session is a stable id of the agent session and label a short name for it, the basename of its working directory (else the first 8 characters of the session id). Cards show them as "claude · moth-repo said…" in moth's page and moth tail. session and label are optional: an older adapter without them counts as one session per harness. MOTH_URL defaults to http://127.0.0.1:4747.

Every adapter follows the same rules:

  • It uses a 1 s timeout and never throws or prints. The JS and Hermes adapters never wait inside the agent's own code path; the Claude Code and Codex hooks run in the foreground, so a one-shot run can't kill them before they post (the wait is the hook's start-up plus one local POST that moth only queues, 1 s at most).
  • It posts nothing when moth is down. If moth is slow, it hangs up after 1 s, but moth keeps the text: serve.py reads the request body first and the page polls /api/log.
  • It posts nothing for subagent turns, aborted turns, or turns with no assistant text. It never re-sends an older reply.
  • It needs no dependencies beyond Python's stdlib or the harness's own JS runtime (fetch).
  • It sends moth's per-install token (X-Moth-Token, read from ~/.moth/token on every post; moth makes the file on its first run, MOTH_TOKEN_FILE overrides). Python adapters bypass any HTTP proxy set in the environment.

moth doesn't react to every turn: at most one reaction per 2 minutes (--react-every), except when the agent apologises, reports a failure or an error, or writes a very long message. While you're chatting with moth (the last 60 s, --quiet-after) it holds its reaction until you stop. Every turn still shows up as a card; only ~25 words of each one stay in moth's memory after it reacted, so agent turns don't push your conversation out.

Several sessions at once (any mix of harnesses):

  • Batching. When moth may react, it first waits --batch-window seconds (default 4) for other sessions to finish too. Then it reacts to the most interesting message: a failure, apology or error first, then a question to you, then a long message, then a routine "done" (the newest wins a tie). The others show as skipped cards. Batched cards appear when the window closes, the chosen one last, so moth's reply sits right under it. --batch-window 0 turns this off, and --react-every 0 --quiet-after 0 (the judge tooling) still reacts to every message at once, with no window.
  • No mix-ups. moth's history keeps one agent turn per session: when it reacts to session B, the older agent turns of B, and all but the newest of every other session, are dropped together with moth's replies to them (agent turns carry a session marker in the history that the model never sees). moth's input stays exactly what it trained on ([the agent said] + the message); session names never go into it.
  • Seeing them. The page shows "2 agents active · 1 skipped" (sessions that posted in the last 30 min) and, with two or more sessions, a dropdown to show one session's cards (moth's replies always show). moth tail --session <label> does the same in a terminal. When two active sessions share a folder name, moth adds the end of the session id: "moth (3f2a)".
  • Synthetic batch turns ("three tasks finished, one says sorry") were left out on purpose: moth only ever trained on real agent messages, and a counted summary would be out of distribution. It couldn't be tested here without running the model.
Harness session label from
Claude Code Stop input session_id Stop input cwd
Codex CLI Stop session_id / notify thread-id cwd (both)
Hermes Agent post_llm_call session_id agent.runtime_cwd: gateway session cwd, else TERMINAL_CWD, else launch dir
Pi ctx.sessionManager.getSessionId() ctx.cwd
OpenCode the event's sessionID Session.directory (from session.created/updated), else the plugin's directory
dsh session.id process.cwd() (dsh is started in the project)

moth has to be awake for the adapters to reach it. The one-command way is the desktop pet, cd moth-112m/code && python -m moth.pet: it starts the server itself and stops it when you quit the pet. For the snippets, or without a desktop, run cd moth-112m/code && python -m moth (just the server, in the terminal; the pet uses it if it's already running). It prints the snippets below with the real absolute paths of your copy filled in. The chat page is served at http://127.0.0.1:4747/ but only opened in your browser with python -m moth --open (or --browser; the old --no-browser is accepted and does nothing). Below, paths are relative to the Hugging Face download (hf download darioooooo0o/moth-112m --local-dir moth-112m); hooks need absolute paths, so put your own folder in front of moth-112m/.

Harness File Mechanism Turn-end event
Claude Code claude_code.py command hook Stop
Codex CLI codex.py hooks.json command hook, or legacy notify Stop / agent-turn-complete
Hermes Agent hermes/moth/ plugin post_llm_call
Pi pi/moth.ts extension agent_end + agent_settled
OpenCode opencode/moth.js plugin session.status idle (+ deprecated session.idle)
dsh dsh/moth.mjs cordis plugin session/event → turn/end

Test them all offline against a stub moth: python3 moth/integrations/test_integrations.py (about 50 s, 127.0.0.1 only).

Or let moth install them (from moth-112m/code, or anywhere after pip install, as moth ...):

  • python -m moth hooks lists the harnesses found on this machine and whether moth is hooked into each. It also flags copies that are outdated, broken or point to another moth folder.
  • python -m moth hook claude-code (or codex, hermes, pi, opencode, dsh, or --all for every harness found) does what the sections below describe. It shows the plan and asks first (--yes skips the question, --dry-run only shows the plan), then prints what changed.
    • Every config file it edits is backed up first as <file>.bak-moth-<timestamp>. JSON, YAML and TOML configs are merged, so other hooks and keys stay. Installing twice changes nothing.
    • Copied adapters (Pi, OpenCode, Hermes) that differ from this folder's copy are replaced, for example after an update. The old Hermes folder is kept in ~/.moth/backups/, outside the plugins folder.
    • Codex: the hooks.json hook by default, or --legacy-notify. moth never installs both. Approve the hook once in codex (/hooks).
    • --project DIR: Claude Code's DIR/.claude/settings.json. --profile NAME: the dsh profile.
  • python -m moth unhook <harness>|--all removes moth's entries only. It deletes a copied adapter only if it is moth's.

Privacy: what moth sees, and where it goes

  • What moth sees: two things only.

    • The final message of each agent turn, posted by the adapters above. Each post carries a session id and the project folder's name as its label.
    • What you type to moth: in the chat page, the pet, moth tail, Pi's /moth or the Claude Code band.

    It never gets your prompts to the agent, the agent's tool calls, or your files. Its own tools reach only its home folder.

  • Everything stays on your machine. The server listens on 127.0.0.1 only, unless you start it with another --host. The adapters post to http://127.0.0.1:4747, unless you set MOTH_URL.

    • moth's code goes online for two things only, both from the Hugging Face Hub: downloading the weights (first run of a pip install) and moth update.
    • The browser chat page embeds its two fonts, so opening it doesn't fetch anything from outside either.
    • The training and eval scripts in code/moth/ talk to whatever LLM server you point them at. Running moth never uses them.
  • Where it's stored: ~/.moth/. That holds the token, config.json, pidfiles while moth runs, backups/ from moth hook, and moth's home folder home/: its files, its diary, and anything it wrote down from your chats.

    • serve.log holds the server's output when the pet starts it: status lines and errors, no chat text.
    • The conversation itself lives only in RAM. Stopping moth wipes it, and so does the page's "new chat" button. moth's memory of its own last 16 reactions (token ids, so it doesn't repeat a joke) is RAM-only too. "New chat" keeps it; stopping moth or moth forget wipes it.
    • In the dev checkout, the home is live/home/ and the logs are live/*.log.
  • The agent never receives anything back. The adapters only post. They ignore the answer, print nothing and exit 0, so nothing reaches the agent's context.

    • moth's lines show only in displays you turn on: the pet, moth tail, the Claude Code band, and the Pi widget or OpenCode toast with show_in_harness on. None of them feed the model.
    • What you say to moth goes to moth only.
  • Wiping it. Stop moth first, then:

    • moth forget deletes its logs.
    • moth forget --all also resets its home folder, but only a folder moth made itself.
    • moth forget --all --token also deletes the token and settings.
    • On a moth that's still running, moth forget deletes no files but makes it forget the chat and its last reactions (POST /api/reset {"everything": true}).

    moth unhook --all removes the hooks.

Seeing and talking to moth

The adapters above only tell moth about each turn. They hang up after 1 s and never see its reply. The pieces below are the other half: they show what moth says back, and they let you talk to it. Each one polls GET /api/says (or the full /api/log) for new say events and never posts to /api/agent, so moth never hears a turn twice. Install the adapter for your harness as well.

Each in-harness display shows moth's reaction to its own session only. With several agents running, moth's newest line is often its reply to another one (seen live: Claude Code's band showed moth's reply to Hermes). So the Claude Code band, the Pi widget and the OpenCode toast ask /api/says?source=<harness>&session=<id> for the session they belong to, and show nothing rather than another session's line. The Hermes Desktop pet asks for source=hermes (every Hermes session in that app: the renderer can't map its sessions to the agent's ids). The desktop pet, moth tail and the chat page still show everything. Codex and dsh have no in-harness display.

moth needs a moment to think. A display that waits after a turn (Pi, OpenCode, when turned on) waits up to about 4 s. A slower line is not lost: it shows on the next poll, after the next turn, or right away in moth tail and the pet.

Venting stays private. Whatever you say to moth, and what moth says back, never enters the agent's context. That is why there is no /moth in Claude Code or Hermes. In Claude Code a command's output row is read by the model. In the Hermes gateway, a plugin command sent mid-turn is handed to the running agent. Pi's /moth is kept, because Pi runs extension commands outside the conversation (details per harness below).

The server only talks to this machine, and to whoever has the token.

  • It answers only requests whose Host is 127.0.0.1:<port> or localhost:<port>, so a website that points its own domain at 127.0.0.1 (DNS rebinding) gets a 403.
  • POST /api/chat, /api/agent and /api/reset, and the full log (GET /api/log, every agent reply and everything you typed) need the token in X-Moth-Token. The page moth serves has it built in; the adapters and moth tail read ~/.moth/token.
  • GET /api/says?since=n is moth's own lines and nothing else, without a token. That is all a display needs, and what the Hermes Desktop pet and the Pi / OpenCode / Claude Code displays use. &source=<harness> and/or &session=<id> keep only moth's reactions to that harness / session (moth tags each reaction in its log with the turn it answers; chat replies have no tag, so they drop out). The answer is the same {"say"} list either way, with no ids or labels in it, and next still counts every event.
  • CORS: local pages (http(s)://localhost or 127.0.0.1, any port) may read answers. Desktop shells (app://…, file://, null: Hermes Desktop packaged loads file://…/index.html, which sends Origin: null) may read /api/says only. Any other origin gets no CORS header, and a POST from a foreign origin, null included, is refused outright.
  • POSTs must be Content-Type: application/json, at most 64 KB, a JSON object; anything else gets a 4xx, not a crash. Every answer carries X-Frame-Options: DENY.
  • Every poll answer has an epoch that changes when moth restarts or the chat is reset. Pass it back (?since=n&epoch=e) and a changed epoch gets you the new log from its start.

In-harness display: off by default (show_in_harness)

The Pi widget and the OpenCode toast come inside adapters you install anyway, so they are off until you turn them on. Off, those adapters still post every turn (the desktop pet and moth tail react as usual); they just don't poll moth or draw anything in the harness.

cd moth-112m/code && python -m moth config show_in_harness on     # or off
cd moth-112m/code && python -m moth config                        # print the settings and where they come from
  • Stored as "show_in_harness": true|false in ~/.moth/config.json (MOTH_CONFIG_FILE overrides the path). The CLI writes it atomically and keeps any other keys.
  • The adapters read the file at every turn, so a change applies from the next turn without restarting Pi or OpenCode. Turning it off also takes down a Pi widget that is on screen.
  • MOTH_SHOW_IN_HARNESS=1 / 0 (also on/off, true/false, yes/no) in the harness's environment beats the file. The environment is fixed when the harness starts.
  • Pi /moth <text> is always registered, because Pi registers commands when it loads the extension and so can't follow a live toggle. Typing it is an explicit request, so it always answers. With the setting on the answer goes in the widget; with it off it comes as a one-off notice, and no widget is left behind.
  • Not governed by it: the Claude Code band mod (loaded only with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS) and the Hermes Desktop pet half (starts disabled; you enable it in the desktop's Plugins page). Loading or enabling them is their opt-in, and unloading or disabling them turns them off. Honouring a default-off setting would hide them from someone who just loaded them on purpose. The Hermes renderer also has no file or environment access to read it. moth tail and the desktop pet are separate programs you start yourself (the pet starts the server too).

moth tail: works with every harness (Codex included)

Run it in a tmux split or a terminal next to the harness:

cd moth-112m/code && python -m moth.tail            # moth's lines + a "you>" prompt to chat with it
cd moth-112m/code && python -m moth.tail --watch    # status pane only, no prompt
tmux split-window -h -l 45 'cd moth-112m/code && python -m moth.tail'    # e.g. beside codex / claude
  • It prints moth's new lines (moth> in lamp yellow, wrapped to the pane), a dim agent said: ... one-liner for each turn moth heard, and a dim $ cmd line when moth uses a tool. It shows the last 3 events on connect (--history N).
  • Lines you type go to /api/chat, and the reply arrives through the same poll. A moth line that arrives while you type is printed above the prompt, and your half-typed text is kept.
  • When moth is down it prints "moth is asleep" once and retries with backoff (up to 10 s). It follows moth across restarts and /api/reset.
  • Stdlib only. It doesn't import torch, so any python3 works. --url or MOTH_URL changes the address.

Desktop pet: a moth flying around your screen (python -m moth.pet, any harness)

pip install PySide6                              # optional: only the pet needs it, moth itself doesn't
cd moth-112m/code && python -m moth.pet          # --size 64, --no-wander (sits in a corner), --url, --token-file
python -m moth.pet -- --react-every 0            # flags after -- go to the server the pet starts
python -m moth.pet --export-petdex ./moth-pet    # the same moth as a Petdex / Codex pet (pet.json + spritesheet.png)
python -m moth.pet --skin <pet-id>               # wear an installed Petdex pet instead (or --skin ./some-pet-folder)
python -m moth config pet_skin <pet-id>          # ... every time (default moth); update_check on|off for the bubble
  • One command. At start the pet asks GET /api/health on its URL. If a moth answers, it uses that one and never stops it. If nothing listens, it starts python -m moth.serve itself (same Python, CPU, this download's weights), dozes with "waking up…" while the model loads, and stops that server again when the pet quits (menu quit, Ctrl-C, SIGTERM/SIGHUP, the window closed; on Linux also if the pet is killed). The server runs in its own process group and logs to ~/.moth/serve.log. If something that isn't moth holds the port, or less than ~3 GB of memory is free, it starts nothing and says why in the bubble.

  • Flags: --no-server (never start one, just wait for moth like before), --model DIR (the weights for a server the pet starts), --server-arg=--flag (repeatable; --server-arg=--react-every --server-arg=0) or anything after -- for that server. python -m moth / python -m moth.serve are still the way to run moth headless or in a terminal.

  • A small moth, drawn in the page's style, flies around on top of your windows. It drifts, is drawn to the mouse cursor, lands on screen edges to rest, and dozes after 3 idle minutes. When a new line arrives it flutters and says the line in a speech bubble (wrapped at 32 characters, shown for 6-10 s, queued). When ~/.moth/token is readable, a reaction gets a small claude · repo caption, taken from /api/log. Without the token the pet uses /api/says.

  • Click the moth to type to it (/api/chat, needs the token); the reply comes back in the bubble. Drag it to move it. Right-click for: open chat page, mute bubbles, sleep, show moth in harnesses, skin, check for updates, quit. It never takes focus, except for that input box. Where the desktop has a system tray, a tray icon has the same menu (no tray, e.g. on some Wayland setups: no icon, the right-click menu is still there).

  • Skins. --skin ID|FOLDER, python -m moth config pet_skin ID or right-click → skin: the pet can look like any Petdex pet you have installed (npx petdex install <pet>). Ids are looked up in ~/.petdex/pets/<id>/, then $CODEX_HOME/pets/<id>/ and ~/.hermes/pets/<id>/; the menu lists them all. The pet's sheet must be the Petdex 1536x1872 grid. Flying uses running-right / running-left, resting idle, dozing waiting, excited jumping or waving, waiting for a reply review; the bubble sits on the pet's visible top. A missing or broken pet means moth, and the bubble says why. moth ships no third-party pets; they keep their own licences.

  • Update bubble. Once a day (and on "check for updates") the pet runs python -m moth update --check in the background and, if there's a new version, says so: "run python -m moth update", or pip install -U moth-companion for a pip install. It never updates anything itself. Off: python -m moth config update_check off; HF_HUB_OFFLINE=1 or MOTH_OFFLINE=1 also skips it.

  • When moth is down it dozes with "zzz… (moth server asleep)" and retries with backoff (up to 30 s). If the server the pet started dies, the bubble points at its log instead.

  • Wayland doesn't let an app place its own window. On a Wayland session the pet asks Qt for XWayland (QT_QPA_PLATFORM=xcb;wayland, only if you haven't set it); without XWayland it stays where the compositor puts it (drag still works). Under XWayland the cursor position only updates over X windows, so the pull to the cursor fades when the cursor seems to stop. It lands on screen edges, not on other windows' edges.

  • --export-petdex DIR writes a 1536x1872 sheet (8x9 cells of 192x208: idle, running-right/left, waving, jumping, failed, waiting, running, review). Copy DIR to $CODEX_HOME/pets/moth (Codex, pick it in /pets), to ~/.hermes/pets/moth (hermes pets select moth), or to ~/.petdex/pets/moth (the Petdex desktop app). Codex picks pets by folder name (custom:moth).

  • The pet only reads moth and posts what you type; it never posts to /api/agent.

Claude Code: band above the prompt, with a field to talk to moth (mod)

claude_code_mod/ is a mod (function hooks, early-access API). It draws moth's newest reaction to this session in the band above the prompt (/api/says?source=claude-code&session=<$.session.id()>, the Stop hook's session_id, read on every poll so /clear follows), polling every 2 s, with a text field under it. The reply to what you type there comes back with the send. Focus the band with ctrl+x tab or a click, type, and press Enter. Esc returns to the prompt. Keep the Stop hook above as well, since it is what tells moth about each turn. Try it for one session:

claude --plugin-dir moth-112m/code/moth/integrations/claude_code_mod

To load it in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json ("CLAUDE_CODE_PLUGIN_DIRS": "/abs/path/to/moth-112m/code/moth/integrations/claude_code_mod").

  • MOTH_URL changes the address. It is read when the session starts.
  • Kept out of Claude's context. The field's Enter goes to the mod's own onSubmit, which POSTs to moth directly. The type definitions say an Input's submit starts "No model turn unless it asks one" (InputProps.onSubmit in the 2.1.302 claude-code.d.ts), and the band is drawn UI, not a transcript row. A slash command was rejected because its output is "the row the model also reads" (CommandRunResult.text), and a prompt.submit { drop } comes too late for a plugin command, which has already run by then.
  • Mobile has no Input, so there the band shows moth's line only.
  • It reads moth's lines from /api/says; a send reads the token from ~/.moth/token ($.fs.read).
  • claude plugin validate moth-112m/code/moth/integrations/claude_code_mod passes on 2.1.302.

Hermes: desktop pet (chat in moth tail)

  • No /moth. A register_command reply is not added to the agent history when Hermes is idle (CLI _run_plugin_slash_command only prints it; the gateway's idle path returns before a turn). But in the messaging gateway, a plugin command sent while a turn runs never reaches the plugin. resolve_command knows only built-ins, so _hm_busy_slash_or_photo lets it fall through to _hm_busy_steer / _hm_busy_interrupt, which hand /moth … to the running agent as text (gateway/run_inbound.py). The hooks that run earlier don't fix this: pre_gateway_dispatch runs before auth, and post_gateway_admission runs after the busy lane. So chat with moth in moth tail.
  • desktop/plugin.js, the Hermes Desktop half. It shows moth's lines in the pet's speech bubble (ctx.pet.say): one line at a time under a stable id, so each new line replaces the last; lines over 120 characters are cut into up to 3 pieces shown one after another, each up for 4–30 s. It polls every 2.5 s with ctx.setInterval, for moth's reactions to Hermes turns only (source=hermes). It can't narrow that to one Hermes session: the desktop's gateway events carry the TUI session id, while the agent posts its session key (tui_gateway _make_agent(sid, key, session_id=key)).

The install is the same cp -r as above. Hermes Desktop copies plugins/moth/desktop/ into <hermes home>/desktop-plugins/moth/, and such a half starts disabled. Enable "moth" in the desktop's Plugins page, and turn the pet on in Settings. The desktop half reads http://127.0.0.1:4747 (the renderer has no environment, so edit MOTH in plugin.js to change it). Hermes's own desktop lint (hermes_cli/plugin_validate_desktop.py) finds nothing in it.

Pi: widget above the editor + /moth

pi/moth.ts now also:

  • with show_in_harness on (see above), polls the log for up to 4 s after each posted turn and puts moth's reaction to this session in a widget above the editor (ctx.ui.setWidget("moth", ...), interactive sessions only; source=pi&session=<getSessionId()>, nothing without a session id);
  • registers /moth <text>, which shows the reply in the same widget (or as a notice where there is no widget, or when show_in_harness is off). Kept out of the agent's context. AgentSession.prompt() runs an extension command's handler and returns before any message is made (dist/core/agent-session.js _tryExecuteExtensionCommand), and interactive mode sends input typed mid-stream and during compaction through prompt() too (dist/modes/interactive/interactive-mode.js). The handler only calls ctx.ui. The typed line is kept only in the editor's local up-arrow history.

The install is unchanged. The poll timers are unref'd, so pi -p doesn't wait on them.

OpenCode: toast

With show_in_harness on (see above), opencode/moth.js also polls the log for up to 4 s after posting and shows moth's reaction to that session as a TUI toast (client.tui.showToast, title "moth", 8 s; source=opencode&session=<sessionID>, with a cursor per session, so two sessions in one OpenCode each get theirs). The install is unchanged. There is no /moth here: use moth tail to chat.

dsh: not built (options)

  • A client slot in our cordis plugin. dsh/moth.mjs could poll /api/says after turn/end and hand moth's line to whatever display surface dsh exposes to plugins, the same way as Pi's widget.
  • @linxin666/dsh-pet. If that pet plugin is installed, its announce call could carry moth's line.

Neither was checked against dsh sources (dsh isn't installed here). Until then, use moth tail next to dsh.


Claude Code

Add this to ~/.claude/settings.json, or to a project's .claude/settings.json, merging with any hooks already there:

{
  "hooks": {
    "Stop": [
      { "hooks": [ { "type": "command", "command": "python3 /abs/path/to/moth-112m/code/moth/integrations/claude_code.py", "timeout": 3 } ] }
    ]
  }
}
  • The Stop input carries last_assistant_message, "the text content of Claude's final response". The docs advise using it instead of transcript_path, which "isn't guaranteed to include the final message at Stop time". The transcript is only a fallback for older versions that don't send the key, and even then only the current turn is read.
  • Subagents fire SubagentStop, not Stop, so they are skipped.
  • No "async": true. A background hook works in the interactive TUI, but a one-shot claude -p kills it when it exits, before it posts (seen live on 2.1.302). In the foreground the hook posts first. It is quick: Python's start-up plus one POST to 127.0.0.1, which moth only queues, and it gives up after 1 s (timeout: 3 is Claude Code's hard stop). moth hook claude-code rewrites an older async: true entry, and moth hooks flags it as outdated.
  • Source: https://code.claude.com/docs/en/hooks (Stop input, SubagentStop input, command hook fields). The field is also present in the local binary, 2.1.302.

Codex CLI

Install one of the two options below, not both, or every reply will be posted twice.

A. Stop hook (preferred). Create or merge ~/.codex/hooks.json:

{
  "hooks": {
    "Stop": [
      { "hooks": [ { "type": "command", "command": "python3 /abs/path/to/moth-112m/code/moth/integrations/codex.py", "timeout": 3 } ] }
    ]
  }
}

Then start the interactive codex once and approve the hook in the startup hook review, or later with /hooks. Untrusted or modified user hooks are silently skipped, including in codex exec. Approval stores a trusted_hash in the config, so any edit of the entry needs a new approval (even async true → false).

  • No "async": true, as for Claude Code: codex exec drops a background hook when it exits, before it posts (seen live on 0.163.0; the interactive TUI posted either way). moth hook codex rewrites an older async: true entry; approve it again afterwards.

  • Windows: Codex runs hook commands through PowerShell (powershell -NoProfile -Command <command>, the session's shell: core/src/session/mod.rs build_hooks_config). There a command that starts with a quoted path is a string, not a call, so a Python path with a space (C:/Program Files/..., a venv under C:/Users/First Last) needs the call operator: & "C:/Program Files/Python312/python.exe" "C:/.../codex.py". moth hook codex writes that (and moth hooks flags an older entry without it); without it Codex ran nothing, silently (seen in tests/e2e).

  • Hooks are a stable feature, enabled by default (features/src/lib.rs, CodexHooks, key hooks, at rust-v0.163.0). They are loaded from <config folder>/hooks.json (hooks/src/engine/discovery.rs).

  • The Stop input carries last_assistant_message (hooks/src/events/stop.rs).

  • User Stop hooks run only for root turns:

    • Thread-spawned subagents fire SubagentStop.
    • Internal subagents dispatch no hook at all.
    • Memory consolidation runs only managed hooks.

    Sources: core/src/hook_runtime.rs run_turn_stop_hooks, stop.rs select_handlers.

  • Trust: hooks/src/engine/discovery.rs (hook_trust_status, handlers pushed only when Trusted or Managed), tui/src/hooks_rpc.rs, and tui/src/startup_hooks_review.rs.

B. Legacy notify. Add this to ~/.codex/config.toml. There is only one notify slot, so this replaces any existing notifier:

notify = ["python3", "/abs/path/to/moth-112m/code/moth/integrations/codex.py"]
  • Codex appends {"type":"agent-turn-complete", ..., "last-assistant-message": ...} as the last argv argument and spawns the program with null stdio, without waiting for it (hooks/src/legacy_notify.rs).
  • It is marked for removal ("TODO: Remove this hook ... when legacy notify support is removed").
  • It is not gated by session source: core/src/session/turn.rs calls run_legacy_after_agent_hook for every turn. So subagent and memory-consolidation turns are probably posted too.
  • Source: https://github.com/openai/codex/tree/rust-v0.163.0/codex-rs

Hermes Agent

cp -r moth-112m/code/moth/integrations/hermes/moth ~/.hermes/plugins/moth
hermes plugins enable moth      # or add `moth` under plugins.enabled in ~/.hermes/config.yaml
  • post_llm_call fires once per turn after the tool loop, with assistant_response set to the final text. It fires only on turns that produced a response and were not interrupted.
  • delegate_task children run with platform="subagent" and are skipped.
  • The POST runs on a daemon thread, so the hook returns at once.
  • Sources: website/docs/user-guide/features/hooks.md#post_llm_call and website/docs/developer-guide/plugins/index.md in the Hermes repo. The firing site is agent/turn_finalizer.py _apply_output_hooks; the subagent platform is set in tools/delegate_tool.py. Checked against a local hermes-agent checkout at c57331677d, 2026-10-10.
  • User plugins are opt-in (plugins.enabled).

Pi

cp moth-112m/code/moth/integrations/pi/moth.ts ~/.pi/agent/extensions/moth.ts
# or for one run:  pi -e /abs/path/to/moth-112m/code/moth/integrations/pi/moth.ts
  • agent_end (messages = the messages generated by that run) can be followed by retries, overflow recovery or queued work, so the adapter only remembers the last assistant text there.
  • It posts on agent_settled, which means "Pi will not continue automatically", unless that event has aborted.
  • agent_start clears the remembered text.
  • Assistant messages with stopReason error or aborted are ignored. Only text blocks count.
  • Sources: docs/extensions.md, docs/json.md and docs/cli-integration.md in the package, and https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts (AgentStartEvent, AgentEndEvent, AgentSettledEvent). Checked against main (v1.3.0) and the local 1.1.0 dist/core/extensions/types.d.ts.
  • Pi has no built-in subagents. Subagent extensions that spawn child pi processes will load this extension too, and those children will post.

OpenCode

cp moth-112m/code/moth/integrations/opencode/moth.js ~/.config/opencode/plugins/moth.js     # global
# or: <project>/.opencode/plugins/moth.js
  • Idle events carry only sessionID, so the adapter collects state from the event stream:
    • From message.updated, it records which message ids are assistant messages. Compaction (summary) messages are skipped; an error such as an abort means no reply.
    • From message.part.updated, it takes text parts that aren't synthetic or ignored. part.text is the full text, and the final update has time.end.
    • From session.created / session.updated, it treats sessions with an info.parentID as subagent (task tool) sessions and skips them.
  • On session.status with status.type === "idle", and on the deprecated session.idle fired at the same moment, it posts the newest assistant message's text and then forgets that session.
  • Sources: https://opencode.ai/docs/plugins/ (plugin dirs, event hook, session.idle / session.status), plus packages/schema/src/session-status-event.ts and packages/schema/src/v1/session.ts at tag v1.19.3 in https://github.com/anomalyco/opencode, plus the local SDK types 1.2.10 (@opencode-ai/sdk types.gen.d.ts).

dsh (DeepSeek Harness)

Add this to ~/.dsh/profiles/<profile>/cordis.patch.yml, a YAML list, next to any existing entries:

- insert: [{ id: moth, name: '/abs/path/to/moth-112m/code/moth/integrations/dsh/moth.mjs' }]

Or pass it for one run: dsh --patch /path/to/patch.yml ..., where the patch file holds the same list.

  • session/event is the "post-commit, fire-and-forget append feed" of every session-log event. From it the adapter:
    • remembers the text blocks of the newest assistant/message in the turn, skipping interrupted: true;
    • on turn/end with reason.kind completed or max-tokens, posts that same turn's text;
    • posts nothing for aborted, error and blocked turns.
  • Child sessions announced by subagent/start (info.id) are skipped.
  • Sources: https://github.com/deepseek-ai/deepseek-harness docs/subsystems/session.md (session/event, SessionEventMap, TurnEndReasonMap), docs/subsystems/subagent.md and packages/subagent/subagent/src/types.ts (SubagentRunInfo.id), packages/llm/llm/src/types.ts (TextBlock). Checked at tags dsh-v0.2.0-rc.2 (npm latest) and dsh-v0.2.1-rc.2. The agent/turn-stopping hook was not used because it can steer another step, while turn/end is the committed close.

Updating

From moth-112m/code, with moth stopped: python -m moth update --check shows what changed (exit 10 = update available), python -m moth update fetches only the changed files into the same folder, and python -m moth update --rollback puts back the previous version. python -m moth --version prints the version and Hub revision. Hook commands that point into code/moth/integrations keep working; re-copy any adapter you copied elsewhere (Pi moth.ts, OpenCode moth.js, the Hermes plugin folder) when the update says integration files changed: python -m moth hooks flags outdated copies and python -m moth hook <harness> replaces them. ~/.moth/ (home, token) is never touched.


Not verified live

All six adapters, the Claude Code band, the Pi widget and the OpenCode toast have since run live once, on 2026-10-18 (../../video/raw/INDEX.md in the dev checkout). That run found the async one-shot bug and the cross-session display bug, both fixed since; the fixes were checked offline as described below, not in a new live run. Each adapter is also checked with fake payloads shaped from the sources above (test_integrations.py):

  • correct text posted;
  • subagent, aborted and empty turns skipped;
  • no output, exit 0;
  • under 1.8 s against a moth that answers after 2 s;
  • no crash with moth down or a bad URL.

Open points:

  • Session ids and labels come from the payload fields in the table above, checked against sources (Claude Code docs, Codex stop.rs / legacy_notify.rs at rust-v0.163.0, Hermes agent/turn_finalizer.py and agent/runtime_cwd.py at c57331677d, Pi 1.1.0 ExtensionContext types, OpenCode SDK 1.2.10 Session), and only with fake payloads. dsh's session object was not checked for a cwd field, so its label is process.cwd(). Hermes in a messaging gateway without a configured cwd labels sessions with the gateway's launch folder.
  • Claude Code and Codex Stop: if another Stop hook blocks and makes the agent keep going, the reply from before the block has already been posted, and the later reply posts too. An async observer can't tell them apart.
  • Pi: a follow-up you queue mid-run runs before agent_settled, so only the last reply in that chain posts.
  • Claude Code / Codex Stop hooks were run live on 2026-10-18 (Claude Code 2.1.302, Codex 0.163.0, ../../video/raw/INDEX.md in the dev checkout): async: true posted from the interactive TUIs but not from claude -p / codex exec, so the hooks now run in the foreground.
  • Real CLIs, fake model (tests/e2e/, .github/workflows/e2e.yml, GitHub-hosted Linux, macOS and Windows): run_e2e.py installs the hooks with moth hook ... --yes into a scratch home from a folder with a space (and again with a venv Python in a folder with a space), runs claude -p (2.1.302), codex exec (0.163.0), pi -p (1.3.0) and opencode run (newest) against a fake Anthropic / OpenAI API, and checks that serve.py's real handler got the final reply with the harness's own session id and the folder label, before the CLI exited. Codex's run uses --dangerously-bypass-hook-trust; a control run without it must post nothing (the unapproved hook is skipped). The approval itself was checked by hand in the interactive codex (tui_live.py --tui codex, Linux): the startup review's "trust" writes the trusted_hash, and codex exec then posts without the flag.
  • dsh:
    • Not confirmed: whether a root-level ctx.on('session/event') receives events with no inject, and whether session.id equals SubagentRunInfo.id for in-process children.
    • Remote (local: false) subagents emit no local session events anyway.
  • OpenCode:
    • Not confirmed live: that the assistant message.updated arrives before its idle event.
    • In one-shot opencode run / pi -p, the process may exit before the POST lands. In tests/e2e (one short reply, local fake model) both posted before exit on Linux, macOS and Windows; a slow machine may still lose it.
  • Pi: child pi processes spawned by subagent extensions are not filtered.
  • Hermes: in one-shot runs (hermes -q), the daemon thread can die at exit before posting.

Display layer: everything was checked against a stub moth with a real log (Display in test_integrations.py):

  • moth tail: the watch output, wrapping, history, restart, chat echo, and "asleep" said once. Interactive redraw was checked by hand in a pty (libedit Python 3.14).
  • Hermes registers no slash command.
  • Pet plugin.js under node with a fake ctx: module shape, history skipped, the moth id, ttl bounds, 120-char pieces, moth down, and no ctx.pet.
  • Pi widget and /moth with a fake ctx.ui.
  • OpenCode toast with a fake client.tui. The event hook doesn't wait on the poll.
  • Per-session displays: another harness's reaction landing last is not shown by the Pi widget, two OpenCode sessions in one process each get their own toast, the Hermes Desktop pet voices Hermes reactions only, and serve.py's /api/says?source=&session= filter (stripped answer, next unchanged, no ids leaked).
  • The Claude Code band, live in the real interactive claude 2.1.302 (tests/e2e/tui_live.py, Linux, tmux, scratch home, fake model, serve.py's real handler): $.session.id() equals the Stop hook's session_id (the band polls /api/says?...&session=<id> every 2 s, the hook posts the same id), also after /clear (both switch to the new id, the old line is cleared). With this session's reaction shown, two decoys posted last (another session id; the same id under source=codex) don't replace it. The field: ctrl+x tab focuses it, Enter posts /api/chat with the token from ~/.moth/token ($.fs.read) and shows the answer, no model request follows, and the field comes back empty.
  • Pi's /moth: a reply slower than 4 s is picked up by an unfiltered poll (chat replies carry no session), so in that rare case another session's reaction could show instead.
  • show_in_harness: off by default in Pi and OpenCode (the turn is still posted, no /api/says poll, no widget or toast; Pi's /moth answers with a notice); the env beating the file both ways; a live toggle on → off → on of the file in one Pi / OpenCode process (the widget is taken down, and turning it back on never shows a line from the off turn); Pi's reader and moth/config.py agreeing on every spelling. The python -m moth config CLI and the browser opt-in of python -m moth are in tests/test_config.py.
  • serve.py on the real handler, with torch and the model faked out (Serve): the Host allow-list (look-alikes, a LAN IP, no port, no Host), the token on every guarded route, CORS (local pages everywhere, desktop-shell origins on /api/says only, look-alikes such as localhost.evil.example nowhere), foreign-origin / wrong-type / oversized / malformed POSTs, a bad since, X-Frame-Options, the token in the page but no CORS on it, source filtering, and "new home" refusing a folder without moth's marker.
  • Every adapter sends the token and its source; the Claude Code hook posts past an http_proxy in its environment.
  • Every adapter's session and label from fake payloads, end to end through the real handler and the real Moth.hear_agent onto the log's cards (AdapterToLog); their cleaning in serve.py (id charset, 40-char one-line label, ANSI stripped); the session list only in the token-guarded /api/log, never in /api/says; and moth tail --session.

Not verified:

  • Claude Code mod (the rest was run live, see above): a click to focus the field; the band on macOS / Windows terminals; whether the poller survives a mid-session reload of the mod (it is started in session.start, and a reload drops timers).
  • Hermes Desktop:
    • that the packaged renderer really sends Origin: null, or file://, both of which are allowed;
    • that the renderer's fetch to http://127.0.0.1:4747 isn't blocked by a CSP or by Chromium's private-network rules. serve.py deliberately does not send Access-Control-Allow-Private-Network;
    • that the copied desktop half shows up in the Plugins page as described in electron/desktop-plugins-root.ts.
  • Pi: how ANSI color looks inside a string-array widget. The interactive renderer wraps each string in a Text.
  • OpenCode: that client.tui.showToast works from a plugin in 1.2.10 (the SDK types have it), and that opencode run exits promptly with the poll pending.
  • tail: readline's redraw is called from the poll thread. It is cosmetic, but GNU readline was not tried.
  • Token reads inside harnesses: checked (the Claude Code mod's $.fs.read of ~/.moth/token live; Pi's and OpenCode's node:fs reads in tests/e2e, where moth refuses a post without the right token), but only with no other fs hook installed that might object.
  • Proxies in JS: Node's fetch ignores HTTP_PROXY, but Bun's (OpenCode) honours it; with a proxy set and no NO_PROXY=127.0.0.1, OpenCode's posts may go to the proxy.