SLOPSHOPPER

mesimon

mesimon's bridge into a Claude Code session it started: reports the session's events to the board, refuses writes to the board's files, serves the board's…

newguardtoolprocessagents
★ 4v1.0.0Apache-2.0updated 2026-10-08amitozalvo/mesimon/crates/mesimon-daemon/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mesimon
› fix the failing auth test and add an audit log call ● mesimon: mesimon: the pane's variables were not read at session.start (Error: unset: bin, hookSock, orchSock, session); the next event reads them again ● mesimon: mesimon: the pane's variables were not read at SessionStart (Error: unset: bin, hookSock, orchSock, session); the next event reads them again ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Edit(/work/app/src/auth.ts) ⎿ Denied by mesimon: Mesimon write guard context is unavailable ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ● mesimon: mesimon: the pane's variables were not read at undefined (Error: unset: bin, hookSock, orchSock, session); the next event reads them again ● mesimon: mesimon: the pane's variables were not read at turn.start (Error: unset: bin, hookSock, orchSock, session); the next event reads them again ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mesimon

mesimon.dev

mesimon (Hebrew משימון, "the task instrument" — pronounced me-si-MON) is a terminal kanban board that runs your coding agents. Like Kubernetes is to containers, mesimon is to Claude Code and Codex.

Your agent is the real Claude Code or Codex, in its own terminal: step into it and you have the same prompt, the same slash commands and the same permission dialogs, and mesimon never sits between you and it.

Starting an agent from the board, answering it from the ticket page, and merging its branch

<sub>Start an agent with Shift+Enter on the board. When its card lights, open the ticket and answer with Shift+Enter there, then merge its branch with m. Recorded with a scripted stand-in agent so the take is reproducible; assets/demo/ re-records it.</sub>

Beta. It is used every day, and it still changes under you. Each version's changes are in the CHANGELOG.

What it does

  • Start an agent on a ticket with one key. Your ticket's title and description are its first prompt. Claude Code and Codex both work.
  • Each agent can work in its own worktree, on its own branch, so several agents can change the same repo at once without touching each other's files.
  • The card lights when the agent needs you. The board stays quiet while agents work, and a card moves to REVIEW on its own when its agent is done. It turns the one bright colour on the screen only when its agent asks you a question or waits on a permission.
  • Review and merge with one key. v shows the branch's diff and m merges it, fast-forward only.
  • Agents keep running when you close the board. A background process holds every session, and the next time you open the board everything is where you left it. Idle agents can sleep and wake into the same conversation.
  • Answer your agents from your phone. Remote Control pairs a phone or another browser to your board, so a permission, a question or a plan can be answered from wherever you are. More on mesimon.dev.

Install

curl -fsSL https://mesimon.dev/install.sh | sh

Or with Homebrew:

brew install amitozalvo/tap/mesimon

Then run mesimon doctor. It checks your setup and prints fixes, and changes nothing.

You need macOS on Apple Silicon or Linux (x86_64 or aarch64, WSL2 included), git, and Claude Code or Codex, signed in. On Linux you also need tmux 3.3 or newer; on macOS mesimon brings its own. Requirements in detail covers the rest, and Updating says how new versions arrive.

First run

  1. cd into a git repository and run mesimon.
  2. Press o and type a task as the title, for example Add a --version flag.
  3. Press shift+tab so the line under the title reads ⎇ worktree. The agent gets its own branch.
  4. Press shift+enter. The ticket is saved, an agent starts on it, and its card spins while it works.
  5. When the agent is done, its card moves to REVIEW. Press space on it to read what the agent said, then m twice to merge its branch.

If a card lights before then, its agent is asking you something. Press shift+enter on the card to answer it, or enter to go into the agent's own terminal; ctrl-] brings you back.

shift+enter needs a terminal that reports it, such as iTerm2, Ghostty, kitty or WezTerm. If ? does not list it, press enter in step 4 to save the ticket, then enter again to start its agent, and type the task into it.

? lists every key on the screen you are on. Closing the board does not stop your agents: Stopping everything says how to.

Tour

Your agent, your way. enter on a ticket with a working agent takes you into the agent itself: Claude Code or Codex, exactly as you know it.

From a ticket page into the agent's own terminal, a message typed to it, and back to the board

<sub>enter steps into the agent's own terminal, where you type to it as you always do, and ctrl-] steps back out while it keeps working.</sub>

The ticket page. tab on a card writes its description, and ctrl-k lists the links in its notes.

Writing a ticket's description with tab, opening its page, and opening a link from its agent's note with ctrl-k

<sub>tab adds two lines to the brief, enter opens the page with the note its agent left, and ctrl-k opens one of that note's links.</sub>

Search. / finds any ticket by a few letters of its title, key, column or tag.

Typing csv into the search picker narrows fifteen tickets to one, and enter puts the cursor on its card

<sub>Three letters narrow fifteen tickets to one, and enter scrolls the board to its card.</sub>

The crown. ctrl-o on a card lets its agent file, move, tag and start the other tickets.

Crowning a working agent's ticket with ctrl-o: its agent files two tickets, moves one, tags one and starts an agent on another

<sub>The crowned agent runs the board, and you keep the crown: ctrl-o on its card takes it back. More on the crown.</sub>

Remote Control. Pair your phone from esc › Remote Control and answer your agents from wherever you are.

<img src="assets/demo/remote.png" width="330" alt="Remote Control on a phone: the Now screen, a permission request on a card with Deny and Approve once under it, and two working agents below">

<sub>The card lights on your phone when an agent needs you. Tap to approve, deny, pick an answer or accept a plan, and file a ticket from the + button; it lands when your computer is back. Remote Control is a subscription: what it does and how to set it up is on mesimon.dev. Sharing a board with teammates is next.</sub>

Three promises

  1. A strict write allowlist. mesimon writes to a short list of paths, every one named, and nowhere else: never your shell rc, your git config, your agent configuration or your tmux config.
  2. No config mutation. mesimon doctor prints fixes for you to apply; it never applies one itself.
  3. Zero prompt injection. mesimon adds, removes and reorders no token of your conversation. The board tools it gives its agents, and a brief you can switch on, are shown in full.

The promises in full, with every path mesimon writes. If you find mesimon breaking one, report it.

Learn more

License

Apache-2.0. See LICENSE, NOTICE and TRADEMARK.md.

Source 1 files
hooks/register.ts 1303 lines
1// mesimon's mod (T-574, phase 1 of the road T-573 measured; the turn roads
2// T-575 and T-576). The daemon lays this folder under its state dir and
3// passes it with `--plugin-dir` to a Claude session it starts on the mod
4// road; nothing installs it anywhere.
5//
6// What it does, and all it does:
7//  - TOOLS: the board's tools for this session's tier, registered at
8//    session.start with `$.tool.register` by the names, descriptions and
9//    schemas `mesimon mcp --list` prints (the shim's own, `doctor --mcp`),
10//    and again at a turn's start when that list changed (an update, T-695),
11//    and each call served by `mesimon mcp --call`, which asks the daemon as
12//    the shim does. The daemon checks the tier and the session at every call
13//    (T-577): a registered tool runs without Claude Code's permission check.
14//  - GATE: a structured write (`Write`, `Edit`, `NotebookEdit`) under the
15//    board dir or the state dir, the worktrees under it excepted, is refused
16//    at `tool.call` with `mesimon gate`'s own words, decided here and from
17//    the pane's variables alone, and reported for the feed (T-577).
18//  - RELAY: each event the generated hook set reports, by the same names and
19//    the same matchers, goes up through the real `mesimon hook` binary with
20//    `--road mod`, one after another in the order the events came. Since
21//    T-577 a mod launch carries no hook set, so these are the frames the
22//    daemon ingests.
23//  - APPROVE: a permission dialog is put to `mesimon approve` as the hook
24//    set's entry did, in rounds for as long as the daemon holds it (T-632),
25//    and a person's one-shot answer from Remote Control is returned as the
26//    dialog's decision; no answer leaves the dialog alone.
27//  - BRIDGE: one `mesimon mod-bridge` per session, spawned at session.start
28//    (or by the first event after it, when its read of the variables failed),
29//    whose stdout is the daemon's commands to this session, one JSON line
30//    each: `ping`, answered with a `ModPong` relay; `submit`, a prompt the
31//    daemon delivers (a person's words, or the brief they wrote), submitted
32//    whole as the person's own (`asUser: true`) and reported with
33//    `ModSubmit`; `fill`, the same words put in the session's EMPTY composer
34//    (never over a person's draft) for Claude Code's send-now, which the
35//    daemon presses on the `ModFill` this reports (T-601); `answer`, the
36//    answer a person or the crown chose for a question this session's model
37//    asked, returned in the native dialog's place and reported with
38//    `ModAnswer`.
39//  - HOLD: every `AskUserQuestion` call of the session's own (no subagent's)
40//    is raced between the native dialog, which is drawn as ever and which
41//    the person may answer first, and the board's `answer`.
42//  - COST: each turn's end (`turn.complete`, a subagent's too) goes up as
43//    `ModUsage`: the engine's count of the turn's tokens and, for the main
44//    loop, the rate-limit windows `$.session.usage()` reads at that moment
45//    (T-581). Observed only: the turn's result passes as it came.
46//  - LOAD: the pane's variables are read by the first event and kept once
47//    read whole; a read that fails is tried again by the next event, and the
48//    one that succeeds reports the failures before it as `ModLoadFailed`
49//    (T-594).
50//  - NATIVE (T-657): under `MESIMON_MOD_NATIVE=1` the classic relays go
51//    silent and the same frames, by the hook set's names and shapes, are
52//    built from the engine's own events (`session.*`, `turn.*`, `tool.call`,
53//    `agent.spawn`), which a Team or Enterprise account's security default
54//    lets through where it keeps every `classic.*` event from a person's
55//    mod (T-650, T-651). One named adapter: the `Stop`'s task list, kept
56//    here from the tool results that start a task and `$.agent.list()`.
57//
58// It holds no policy. Every decision is the daemon's.
59//
60// Never (README promise 3, the T-573 never-list): no `$.session.append`, no
61// `context` on any hook, no `prompt.compose` / `prompt.context` /
62// `prompt.section`, no rewrite of a prompt's words, no submit without
63// `asUser: true`, no fill but into an empty composer and whole, no rewrite of the model's tool arguments, no `deny` of a
64// tool but two: the gate's (its words are `mesimon gate`'s, which the hook
65// set already hands the model; deny or nothing, never allow) and a board
66// tool's refusal (the daemon's words, the shim's error result), no `tool.check →
67// allow` beyond the one-shot a person consented to, no `$.model.*`, no
68// `$.session.send`. Every hook here returns `next(e)` as it came, but the
69// question's and the gate's: the question's returns the answer a person or
70// the crown chose, by the dialog's own labels, which is what the native
71// dialog would have returned.
72//
73// Environment, set by the daemon on the pane (`mesimon exec --set`):
74//   MESIMON_MOD_BIN        the mesimon binary
75//   MESIMON_MOD_HOOK_SOCK  the board's hook.sock
76//   MESIMON_MOD_ORCH_SOCK  the board's orch.sock
77//   MESIMON_MOD_SESSION    mesimon's id for this session (not Claude's
78//                          session_id, which `/clear` changes)
79//   MESIMON_MOD_GATE_BOARD the board dir, `<repo>/.mesimon` (the gate's)
80//   MESIMON_MOD_GATE_STATE the state dir
81//   MESIMON_MOD_GATE_ALLOW the worktrees under it, the agent's own
82//   MESIMON_MOD_TOOLS      the tier of tools to register (off: unset)
83//   MESIMON_MOD_NATIVE     `1`: relay from the native events (T-657); else
84//                          from the classic ones, as before
85import type { Register } from 'claude-code'
86
87type Config = { bin: string; hookSock: string; orchSock: string; session: string; native: boolean }
88type Roots = { board: string; state: string; allow: string }
89
90// The hook set's matchers (`hook_settings.rs`), so each relayed frame has a
91// twin. The daemon's unit test holds these lists to the Rust ones.
92const SESSION_START_SOURCES = ['startup', 'resume', 'clear', 'compact', 'fork']
93const SESSION_END_REASONS = ['clear', 'resume', 'logout', 'prompt_input_exit', 'other']
94const STOP_FAILURE_MATCHERS = [
95  'rate_limit',
96  'overloaded',
97  'authentication_failed',
98  'oauth_org_not_allowed',
99  'billing_error',
100  'invalid_request',
101  'model_not_found',
102  'server_error',
103  'max_output_tokens',
104  'unknown',
105]
106const PRE_TOOL_USE_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
107
108// ---- The native road (T-657). A task row the `Stop` lists, as the hook
109// set's `background_tasks` spells one (2.1.283–2.1.289, T-483, T-651): a
110// shell for a backgrounded command and a Monitor watch alike, a `subagent`
111// with its `agent_type`, a `teammate`. A row whose status is one of these
112// is over and is listed no more, as the hook set drops a finished task.
113type Row = {
114  id: string
115  type: string
116  status: string
117  description: string
118  command?: string
119  agent_type?: string
120  teammateId?: string
121}
122const TASK_OVER = ['completed', 'failed', 'killed', 'stopped', 'cancelled', 'interrupted']
123const LEDGER_MAX = 64
124// The compactions the hook set reports (`PreCompact`'s `trigger` words); a
125// `precompute` is speculative and a `plugin`'s is not the person's session.
126const COMPACT_TRIGGERS = ['manual', 'auto']
127
128// ---- The gate (T-577): `mesimon gate`'s rules, in-process. The structured
129// writes it judges, and what the model reads when one is refused: each
130// rule's text is `mesimon_core::verdict::RuleId::reason`, held to it by the
131// daemon's unit test, and the rule's tag is `RuleId::tag`.
132const GATE_TOOLS = ['Write', 'Edit', 'NotebookEdit']
133const RULE_BOARD = 'board_dir'
134const RULE_STATE = 'state_dir'
135const REASON_BOARD =
136  'mesimon owns .mesimon/ — the board is edited through mesimon, not by writing its files. Use mesimon\'s scoped MCP tools instead.'
137const REASON_STATE =
138  'mesimon owns its state directory — sessions, worktree bindings and hook settings are not editable by an agent.'
139// Losing the roots must not turn a guarded write into no opinion
140// (`mesimon gate`'s trusted-environment rule).
141const REASON_NO_ROOTS = 'Mesimon write guard context is unavailable'
142
143// ---- The tools (T-577). The plugin is `mesimon`, so a registered tool is
144// `mcp__mesimon__<name>`: the shim's names, unchanged for the model.
145const TOOL_PREFIX = 'mcp__mesimon__'
146// `answer_agent` and `accept_plan` wait for their delivery (`mesimon mcp`'s
147// ANSWER_WAIT_SECS, 75 s); every other call answers in seconds.
148const TOOL_CALL_TIMEOUT_MS = 90000
149
150// A relay's own bound (`mesimon hook --road mod` has no other).
151const RELAY_TIMEOUT_MS = 5000
152// The permission bridge (T-632): a mod's process lives ten minutes at most,
153// so `mesimon approve` runs in rounds of `PERMISSION_ROUND_SECS` (540 s) and
154// the daemon passes its wait from one round to the next. A round that ran
155// out with the dialog still held exits `PERMISSION_RENEW_EXIT` (75); the
156// rounds together reach `PERMISSION_HOLD_SECS`, a day.
157const APPROVE_ROUND_SECS = 540
158const APPROVE_TIMEOUT_MS = 570000
159const APPROVE_RENEW_EXIT = 75
160const APPROVE_ROUNDS = 160
161
162// The kinds of command this mod reads, said to the daemon by the bridge
163// (`mesimon_core::road::SPEAKS`): a session keeps the mod it was launched
164// with, so a newer daemon sends it only these.
165const SPEAKS = ['ping', 'submit', 'answer', 'fill']
166
167// `mesimon mod-bridge` exits so when the daemon refused it for good (the
168// session is gone, the pane is not its own, another bridge took the seat):
169// `mesimon_core::road::BRIDGE_REFUSED_EXIT`. Not respawned.
170const BRIDGE_REFUSED_EXIT = 3
171const BACKOFF_FIRST_MS = 1000
172const BACKOFF_MAX_MS = 60000
173// A bridge that lived this long was healthy; its successor starts over.
174const HEALTHY_MS = 60000
175const SEEN_MAX = 64
176
177// The pane's variables once a read found all four. A read that threw or
178// came back short is never kept: the next event reads again (T-594).
179let config: Config | undefined
180// The reads that failed before one succeeded, said to the daemon then
181// (`ModLoadFailed`): the one road that can say it is this one, once open.
182let failed: { reads: number; error: string; at: string } | undefined
183let bridge: 'off' | 'on' | 'refused' = 'off'
184let started = false
185// The last relay: the next one starts once it has gone.
186let tail: Promise<unknown> = Promise.resolve()
187const seen: string[] = []
188// The questions held for the board's answer, by tool_use_id: each settles
189// its race with the answer's labels.
190const holds = new Map<string, (answers: Record<string, string>) => void>()
191// The tools this session registered, by full name: the only calls served.
192const registered = new Set<string>()
193// Whether the tools are registered (or there were none to register), and
194// the registration in flight.
195let toolsDone = false
196let toolsRun: Promise<void> | undefined
197// The tier the tools were listed for, and the list last registered whole,
198// as `mesimon mcp --list` printed it (T-695).
199let tier: string | undefined
200let listed: string | undefined
201// The subagent each dialog tool's call ran in, by its tool_use_id, from the
202// `tool.call` beneath its `classic.PreToolUse` (which carries none).
203const agents = new Map<string, string>()
204const AGENTS_MAX = 64
205// What the native road (T-657) keeps between events: the session's cwd, the last
206// session id seen (a `/clear` fires no `session.start`: the next turn's id
207// differing from it is the new conversation), the last `session.end`'s
208// reason, the task ledger, the type each spawned agent was given (for its
209// `SubagentStop`), and the questions the board answered, whose `PostToolUse`
210// the `ModAnswer` report already is.
211let cwd: string | undefined
212let seenId: string | undefined
213let lastEnd: string | undefined
214const ledger = new Map<string, Row>()
215const spawned = new Map<string, string>()
216const boardAnswered = new Set<string>()
217
218/**
219 * The four variables, read at once, and the native road's switch beside
220 * them (T-657; unset is the classic road); a short read throws, naming what
221 * is unset.
222 */
223async function load($: any): Promise<Config> {
224  const [bin, hookSock, orchSock, session, native] = await Promise.all([
225    $.env.get('MESIMON_MOD_BIN'),
226    $.env.get('MESIMON_MOD_HOOK_SOCK'),
227    $.env.get('MESIMON_MOD_ORCH_SOCK'),
228    $.env.get('MESIMON_MOD_SESSION'),
229    $.env.get('MESIMON_MOD_NATIVE'),
230  ])
231  if (!bin || !hookSock || !orchSock || !session) {
232    const unset = Object.entries({ bin, hookSock, orchSock, session }).filter(([, v]) => !v)
233    throw new Error(`unset: ${unset.map(([k]) => k).join(', ')}`)
234  }
235  return { bin, hookSock, orchSock, session, native: native === '1' }
236}
237
238/**
239 * The pane's variables, which do not change for the process: kept once read
240 * whole. A `$` call fails with the dispatch it rides when that dispatch is
241 * abandoned, and the first read rides the first event's, so a failed read
242 * is said in the debug log and tried again by the next event, never kept:
243 * a kept failure was a session whose mod relayed nothing for its whole
244 * life (T-594). The first read that succeeds after a failure reports it.
245 */
246async function settings($: any, at: string): Promise<Config | undefined> {
247  if (config) {
248    if (started && !toolsDone) void bringUp($, config)
249    return config
250  }
251  // Each event reads for itself (T-577): a read shared with another event
252  // failed with that event's dispatch, and three sessions started at once
253  // left two whose every event had awaited the one read session.start's
254  // abandoned dispatch took down.
255  try {
256    config ??= await load($)
257  } catch (err) {
258    failed ??= { reads: 0, error: String(err), at }
259    failed.reads += 1
260    void $.ui.log(`mesimon: the pane's variables were not read at ${at} (${String(err)}); the next event reads them again`, {
261      to: 'debug',
262    })
263    return undefined
264  }
265  if (failed) {
266    const report = failed
267    failed = undefined
268    void relay($, 'ModLoadFailed', 'recovered', report, false)
269  }
270  // `session.start` brings the tools and the bridge up; one whose read
271  // failed left both to the first event that reads them.
272  if (started) void bringUp($, config)
273  return config
274}
275
276/**
277 * Whether this session relays from the native events (T-657): the pane's
278 * `MESIMON_MOD_NATIVE`, read with the variables and kept with them. An
279 * event whose read failed is on no road for its own life: it relays
280 * nothing either way, and the next event reads again.
281 */
282async function nativeRoad($: any, at: string): Promise<boolean> {
283  return (await settings($, at))?.native === true
284}
285
286/**
287 * The gate's roots, absolute, read at every guarded call: three variables
288 * are cheap, and nothing read once can be a failure kept for the session's
289 * life (T-594's lesson).
290 */
291async function gateRoots($: any): Promise<Roots | undefined> {
292  const [board, state, allow] = await Promise.all([
293    $.env.get('MESIMON_MOD_GATE_BOARD'),
294    $.env.get('MESIMON_MOD_GATE_STATE'),
295    $.env.get('MESIMON_MOD_GATE_ALLOW'),
296  ])
297  if (![board, state, allow].every(r => typeof r === 'string' && r.startsWith('/'))) return undefined
298  return { board, state, allow }
299}
300
301/** `.` and `..` folded by spelling, before anything is asked of the disk:
302 * `<repo>/src/../.mesimon/x` names a guarded file whatever exists. */
303export function fold(path: string): string {
304  const absolute = path.startsWith('/')
305  const out: string[] = []
306  for (const part of path.split('/')) {
307    if (part === '' || part === '.') continue
308    if (part === '..') {
309      if (out.length && out[out.length - 1] !== '..') out.pop()
310      else if (!absolute) out.push('..')
311      continue
312    }
313    out.push(part)
314  }
315  return (absolute ? '/' : '') + out.join('/')
316}
317
318/**
319 * Where a path lands: the deepest ancestor that exists, every link
320 * followed, with the rest put back. A file about to be written has no real
321 * path of its own; its folder usually has. A relative path is the session's
322 * working directory's, as the tool reads it.
323 */
324async function placed($: any, path: string): Promise<string> {
325  const parts = fold(path).split('/')
326  const tail: string[] = []
327  while (parts.length) {
328    const probe = parts.join('/') || (path.startsWith('/') ? '/' : '.')
329    let real: string | undefined
330    try {
331      real = (await $.fs.stat(probe, { resolve: true }))?.realPath
332    } catch {
333      real = undefined
334    }
335    if (typeof real === 'string') {
336      const base = real.replace(/\/+$/, '')
337      return tail.length ? `${base}/${tail.reverse().join('/')}` : base || '/'
338    }
339    const name = parts.pop()
340    if (name) tail.push(name)
341  }
342  return fold(path)
343}
344
345const under = (path: string, root: string) => path === root || path.startsWith(root === '/' ? root : `${root}/`)
346
347/**
348 * Which rule a structured write lands under, if any: `mesimon gate`'s
349 * `guarded_by`. The worktrees under the state dir are the agent's own and are
350 * judged first; then the board dir, then the state dir.
351 */
352export async function guardedBy($: any, path: string, r: Roots): Promise<string | undefined> {
353  const target = await placed($, path)
354  if (under(target, await placed($, r.allow))) return undefined
355  if (under(target, await placed($, r.board))) return RULE_BOARD
356  if (under(target, await placed($, r.state))) return RULE_STATE
357  return undefined
358}
359
360/**
361 * The board's tools for this session's tier, registered before the first
362 * turn. No tier (`MESIMON_MOD_TOOLS` unset: the column's tools are off)
363 * registers nothing; a list that cannot be read registers nothing either,
364 * and the session runs without the tools rather than not at all.
365 */
366function registerTools($: any, c: Config): Promise<void> {
367  if (toolsDone) return Promise.resolve()
368  toolsRun ??= registerOnce($, c)
369  return toolsRun
370}
371
372/** The tools, then the bridge: no word goes down before the tools are listed. */
373async function bringUp($: any, c: Config) {
374  await registerTools($, c)
375  startBridge($, c)
376}
377
378async function registerOnce($: any, c: Config) {
379  try {
380    tier = await $.env.get('MESIMON_MOD_TOOLS')
381    if (!tier) {
382      toolsDone = true
383      return
384    }
385    await registerList($, c, tier)
386    toolsDone = true
387  } catch {
388    // The list was not read: the next event tries again, and the session
389    // works without the tools meanwhile.
390  } finally {
391    toolsRun = undefined
392  }
393}
394
395/**
396 * The list as `mesimon mcp --list` prints it now, registered when it is not
397 * the one registered last; a name registered again is replaced. The binary
398 * at `MESIMON_MOD_BIN` is the one an update renames over, so a session that
399 * outlives an update reads the new schemas here (T-695: a crown registered
400 * before `create_ticket` took a workspace was refused for omitting it). A
401 * tool the new list leaves out is no longer served. Throws when the list
402 * cannot be read.
403 */
404async function registerList($: any, c: Config, t: string) {
405  const out = await $.process.run([c.bin, 'mcp', '--list', '--tools', t], { timeoutMs: 10000 })
406  const text = String(out?.stdout ?? '[]').trim()
407  if (text === listed) return
408  const specs = JSON.parse(text)
409  if (!Array.isArray(specs)) return
410  let whole = true
411  const now = new Set<string>()
412  for (const spec of specs) {
413    try {
414      const r = await $.tool.register({ name: spec.name, description: spec.description, inputSchema: spec.inputSchema })
415      if (r && typeof r.tool === 'string') now.add(r.tool)
416    } catch {
417      // That one tool keeps what it had (missing, or its last schema); the
418      // others stand, and the next turn registers the list again.
419      whole = false
420      if (registered.has(`${TOOL_PREFIX}${spec.name}`)) now.add(`${TOOL_PREFIX}${spec.name}`)
421    }
422  }
423  registered.clear()
424  for (const name of now) registered.add(name)
425  listed = whole ? text : undefined
426}
427
428/**
429 * At each turn's start, the list read again: what a refresh registers the
430 * engine offers from the next prompt on, so the turn is not held for it. A
431 * read that fails leaves the tools as they stand.
432 */
433async function refreshTools($: any) {
434  if (!toolsDone || !tier || !config || toolsRun) return
435  const c = config
436  const t = tier
437  toolsRun = registerList($, c, t)
438    .catch(() => undefined)
439    .finally(() => {
440      toolsRun = undefined
441    })
442}
443
444/**
445 * A `tools/call` result, as `mesimon mcp --call` printed it, in the form a
446 * registered tool answers the model: its text whole (an object of content
447 * blocks is refused by the engine's output check, measured), an image as
448 * the API's image block, and a refusal as an error result in the daemon's
449 * own words (`{ isError }` from a hook is not marked an error; a deny is).
450 */
451export function toolAnswer(out: any): any {
452  const content: any[] = Array.isArray(out?.content) ? out.content : []
453  const text = content
454    .filter(b => b?.type === 'text' && typeof b.text === 'string')
455    .map(b => b.text)
456    .join('\n')
457  if (out?.isError === true) return { deny: text || 'mesimon: the call failed' }
458  if (content.some(b => b?.type === 'image')) {
459    return {
460      result: content.map(b =>
461        b?.type === 'image'
462          ? { type: 'image', source: { type: 'base64', media_type: b.mimeType, data: b.data } }
463          : { type: 'text', text: String(b?.text ?? '') },
464      ),
465    }
466  }
467  return { result: text }
468}
469
470/** What the model reads for a refused write: the rule's own text. */
471function denial(rule: string): string {
472  if (rule === RULE_BOARD) return REASON_BOARD
473  if (rule === RULE_STATE) return REASON_STATE
474  return REASON_NO_ROOTS
475}
476
477/** The path a structured write names: `file_path`, or a notebook's. */
478export function gatePath(e: any): string | undefined {
479  const path = e?.file_path ?? e?.notebook_path
480  return typeof path === 'string' && path !== '' ? path : undefined
481}
482
483/**
484 * One frame up through `mesimon hook --road mod`, as the hook set's command
485 * hook sends it. Not awaited unless `wait`: the event goes on at once and a
486 * relay that fails is a missing twin the daemon reports, never an error here.
487 */
488async function relay($: any, event: string, reason: string | undefined, body: unknown, wait: boolean) {
489  try {
490    const c = await settings($, event)
491    if (!c) return
492    await send($, c, event, reason, body, wait)
493  } catch {
494    // A frame that never left: the daemon is down or the host went.
495  }
496}
497
498/**
499 * A classic event's relay: silent under the native road (T-657), where the
500 * same frame is built from the engine's own event, and such an account
501 * fires no classic event for a person's mod anyway. One read of the
502 * variables for the event: a second after a failed one is the same
503 * abandoned dispatch's (T-594).
504 */
505async function relayClassic($: any, event: string, reason: string | undefined, body: unknown, wait: boolean) {
506  try {
507    const c = await settings($, event)
508    if (!c || c.native) return
509    await send($, c, event, reason, body, wait)
510  } catch {
511    // As above.
512  }
513}
514
515/** The frame itself, once the variables are in hand. */
516async function send($: any, c: Config, event: string, reason: string | undefined, body: unknown, wait: boolean) {
517  const argv = [c.bin, 'hook', '--sock', c.hookSock, '--session', c.session, '--event', event]
518  if (reason !== undefined) argv.push('--reason', reason)
519  argv.push('--road', 'mod')
520  const run = runAfter($, tail, argv, JSON.stringify(body ?? {}))
521  tail = run.then(
522    () => undefined,
523    () => undefined,
524  )
525  if (wait) await run
526}
527
528/**
529 * One relay, once the one before it has gone: the hook socket orders frames
530 * by when it accepted them, and two `mesimon hook` processes started a few
531 * milliseconds apart may connect in either order (T-577). Each waits at most
532 * the one before's own bound.
533 */
534async function runAfter($: any, before: Promise<unknown>, argv: string[], stdin: string) {
535  await before
536  return $.process.run(argv, { stdin, timeoutMs: RELAY_TIMEOUT_MS })
537}
538
539/**
540 * `mesimon approve`, as the hook set's PermissionRequest entry ran it: a
541 * person answering the dialog from Remote Control, once. Its decision is
542 * the daemon's, passed through whole; no decision (no phone, no answer in
543 * time, the daemon down) is none, and the dialog stays the person's.
544 */
545async function approve($: any, e: unknown): Promise<unknown> {
546  try {
547    const c = await settings($, 'PermissionRequest')
548    if (!c) return undefined
549    const argv = [c.bin, 'approve', '--sock', c.hookSock, '--session', c.session,
550      '--hold', String(APPROVE_ROUND_SECS), '--renew']
551    for (let round = 0; round < APPROVE_ROUNDS; round++) {
552      const out = await $.process.run(argv, { stdin: JSON.stringify(e ?? {}), timeoutMs: APPROVE_TIMEOUT_MS })
553      const decision = JSON.parse(String(out?.stdout || 'null'))?.hookSpecificOutput?.decision
554      if (decision && typeof decision === 'object') return decision
555      if (out?.exitCode !== APPROVE_RENEW_EXIT) return undefined
556    }
557    return undefined
558  } catch {
559    return undefined
560  }
561}
562
563/**
564 * The command hook's stdin for a `PreToolUse`, rebuilt from the envelope,
565 * with a subagent's `agent_id` as the hook set spelled it: the daemon tells
566 * a subagent's dialog from the session's own by it. `classic.PreToolUse`
567 * carries no `agentId` (T-574); the `tool.call` beneath it does, and is
568 * noted by its call's id (`agents`).
569 */
570export function preToolUseBody(e: any, agentId?: string): Record<string, unknown> {
571  // `consent` rides a `tool.call` envelope alone (the person's words for the
572  // press that raised the call) and is no argument of the tool's.
573  const { tool, tool_use_id, agentId: own, consent, ...tool_input } = e ?? {}
574  void consent
575  const body: Record<string, unknown> = { hook_event_name: 'PreToolUse', tool_name: tool, tool_use_id, tool_input }
576  const agent = typeof own === 'string' ? own : agentId
577  if (typeof agent === 'string') body.agent_id = agent
578  return body
579}
580
581/**
582 * The hook set's `PostToolUse` stdin, from a `tool.call` envelope and the
583 * result it resolved with (T-657): `tool_response` is the tool's record as
584 * it came, which T-651 measured byte-identical to the hook set's.
585 */
586export function postToolUseBody(e: any, response: unknown): Record<string, unknown> {
587  const body = preToolUseBody(e)
588  body.hook_event_name = 'PostToolUse'
589  body.tool_response = response
590  return body
591}
592
593/** The `ModAnswer` report's body: a `PostToolUse` as the hook set spells it. */
594export function answeredBody(id: string, questions: unknown, response?: unknown): Record<string, unknown> {
595  const body: Record<string, unknown> = {
596    hook_event_name: 'PostToolUse',
597    tool_name: 'AskUserQuestion',
598    tool_use_id: id,
599    tool_input: { questions },
600  }
601  if (response !== undefined) body.tool_response = response
602  return body
603}
604
605/**
606 * The `ModAnswer declined` report for a refused plan (T-657): the dialog's
607 * dismissal the hook set never fires (T-447), a fact the native result
608 * carries as `isError`. Same shape as the question's, the plan's input.
609 */
610export function planDeclinedBody(id: string, plan: unknown): Record<string, unknown> {
611  const tool_input: Record<string, unknown> = {}
612  if (plan !== undefined) tool_input.plan = plan
613  return { hook_event_name: 'PostToolUse', tool_name: 'ExitPlanMode', tool_use_id: id, tool_input }
614}
615
616/** A `SessionStart` as the hook set spells it, less the path the daemon finds by id (T-657). */
617export function startBody(source: string, sessionId: string | undefined, dir: string | undefined): Record<string, unknown> {
618  const body: Record<string, unknown> = { hook_event_name: 'SessionStart', source }
619  if (typeof sessionId === 'string') body.session_id = sessionId
620  if (typeof dir === 'string') body.cwd = dir
621  return body
622}
623
624/** A `PreCompact` or `PostCompact` as the hook set spells it (T-657). */
625export function compactBody(event: string, trigger: unknown, agentId?: string): Record<string, unknown> {
626  const body: Record<string, unknown> = { hook_event_name: event, trigger }
627  if (typeof agentId === 'string') body.agent_id = agentId
628  return body
629}
630
631// ---- The task ledger (T-657): the one adapter the native road keeps, where
632// the hook set's `Stop` lists `background_tasks` and `turn.complete` lists
633// nothing. A row begins with the tool result that starts a task, is read
634// against `$.agent.list()` at each turn's end, and ends with the task's
635// notification, a `TaskStop`, or a status that says it is over.
636
637function keep(row: Row) {
638  ledger.set(row.id, row)
639  if (ledger.size > LEDGER_MAX) ledger.delete(ledger.keys().next().value as string)
640}
641
642/**
643 * A tool result that starts a task (the lead's; a subagent's shells die
644 * with it, T-483): Bash's `backgroundTaskId` and Monitor's `taskId` are
645 * shells, as 2.1.283 labels both; an Agent's `agentId` with `isAsync` is a
646 * subagent. A teammate's row is `agent.spawn`'s (its id is the agent's).
647 */
648export function taskStarted(e: any, result: any): Row | undefined {
649  if (typeof e?.agentId === 'string' || !result || typeof result !== 'object') return undefined
650  switch (e?.tool) {
651    case 'Bash': {
652      const id = result.backgroundTaskId
653      if (typeof id !== 'string') return undefined
654      const command = typeof e.command === 'string' ? e.command : ''
655      return { id, type: 'shell', status: 'running', description: command, command }
656    }
657    case 'Monitor': {
658      const id = result.taskId
659      if (typeof id !== 'string') return undefined
660      return { id, type: 'shell', status: 'running', description: String(e.description ?? '') }
661    }
662    case 'Agent':
663    case 'Task': {
664      const id = result.agentId
665      if (typeof id !== 'string' || result.isAsync !== true) return undefined
666      const row: Row = { id, type: 'subagent', status: 'running', description: String(result.description ?? e.description ?? '') }
667      const kind = spawned.get(id) ?? e.subagent_type
668      if (typeof kind === 'string') row.agent_type = kind
669      return row
670    }
671    default:
672      return undefined
673  }
674}
675
676/** A task the lead stopped by hand: `TaskStop`'s `task_id` is a row's id, or a teammate's name or address. */
677function taskStopped(e: any) {
678  const id = e?.task_id
679  if (typeof id !== 'string') return
680  for (const [key, row] of ledger) {
681    if (key === id || row.teammateId === id || row.teammateId?.split('@')[0] === id) ledger.delete(key)
682  }
683}
684
685/**
686 * The notification that ends a background task is a prompt (T-651:
687 * `<task-notification>` with `<task-id>` in the prompt's text, never a
688 * `session.receive`), and one prompt may carry several. Every id it names.
689 */
690export function taskNotified(text: unknown): string[] {
691  if (typeof text !== 'string' || !text.includes('<task-notification>')) return []
692  return [...text.matchAll(/<task-id>([^<]+)<\/task-id>/g)].map(m => m[1].trim())
693}
694
695/**
696 * The `Stop`'s `background_tasks` (T-657): every row still open, its status
697 * read off `$.agent.list()` where the list has it, plus any agent the list
698 * holds live that the ledger never saw start. A row whose status is over
699 * is dropped, as the hook set drops a finished task.
700 */
701export function backgroundTasks(listed: unknown): Record<string, unknown>[] {
702  if (Array.isArray(listed)) {
703    for (const a of listed) {
704      if (typeof a?.id !== 'string' || typeof a?.status !== 'string') continue
705      const row = ledger.get(a.id)
706      if (row) {
707        row.status = a.status
708      } else if (!TASK_OVER.includes(a.status)) {
709        const teammate = typeof a.teammateId === 'string'
710        const row: Row = {
711          id: a.id,
712          type: teammate ? 'teammate' : 'subagent',
713          status: a.status,
714          description: String(a.description ?? ''),
715        }
716        if (teammate) row.teammateId = a.teammateId
717        else if (typeof a.type === 'string') row.agent_type = a.type
718        keep(row)
719      }
720    }
721  }
722  const out: Record<string, unknown>[] = []
723  for (const [id, row] of ledger) {
724    if (TASK_OVER.includes(row.status)) {
725      ledger.delete(id)
726      continue
727    }
728    const listed: Record<string, unknown> = { id: row.id, type: row.type, status: row.status, description: row.description }
729    if (row.command !== undefined) listed.command = row.command
730    if (row.agent_type !== undefined) listed.agent_type = row.agent_type
731    out.push(listed)
732  }
733  return out
734}
735
736/**
737 * The tasks a kept row says are over (T-691): a notification's, whether it
738 * opened a turn or was folded into a running one. A tool's result or the
739 * model's reply that quotes one is not one.
740 */
741export function rowNotified(e: any): string[] {
742  const kind = e?.origin?.kind
743  const blocks = e?.message?.content
744  if (kind === 'tool' || kind === 'model' || !Array.isArray(blocks)) return []
745  return blocks.flatMap((b: any) => taskNotified(b?.type === 'text' ? b.text : undefined))
746}
747
748/** The team a teammate's row names, `<name>@<team>`, if a row does. */
749function teamOf(name: string): string | undefined {
750  for (const row of ledger.values()) {
751    if (row.teammateId?.startsWith(`${name}@`)) return row.teammateId.slice(name.length + 1)
752  }
753  return undefined
754}
755
756/**
757 * A prompt the daemon delivers, submitted as the person's own words and
758 * whole: never framed by the plugin's name, never with context, never
759 * rewritten. Not awaited by the bridge (it resolves when the prompt's turn
760 * starts, a whole turn later behind a running one); its end is reported.
761 */
762async function submitPrompt($: any, id: string, text: string) {
763  let report: Record<string, unknown>
764  try {
765    const r: any = await $.prompt.submit({ text, asUser: true })
766    report = r && r.drop !== undefined ? { outcome: 'dropped', reason: String(r.drop) } : { outcome: 'entered' }
767  } catch (err) {
768    report = { outcome: 'rejected', error: String(err) }
769  }
770  await relay($, 'ModSubmit', id, report, false)
771}
772
773/**
774 * Words for Claude Code's send-now (T-601): into the session's composer,
775 * whole, and only while the box is empty, so a person's draft is never
776 * replaced; the daemon presses the send-now keys on `filled`. A plugin's
777 * `submit` waits for the running turn's end, and the send-now sends only
778 * what stands in the composer, so this is the mod's road to it.
779 */
780async function fillPrompt($: any, id: string, text: string) {
781  let report: Record<string, unknown>
782  try {
783    const box: any = await $.prompt.read()
784    if (typeof box?.text === 'string' && box.text.trim() !== '') {
785      report = { outcome: 'refused', reason: 'draft' }
786    } else {
787      const r: any = await $.prompt.fill({ text, mode: 'replace' })
788      report = r?.isFilled
789        ? { outcome: 'filled' }
790        : { outcome: 'refused', reason: String(r?.refusal ?? 'not_filled') }
791    }
792  } catch (err) {
793    report = { outcome: 'refused', error: String(err) }
794  }
795  await relay($, 'ModFill', id, report, false)
796}
797
798async function relaySingle($: any, e: any, next: any) {
799  const event = String(next.event).replace(/^classic\./, '')
800  void relayClassic($, event, undefined, e, false)
801  return next(e)
802}
803
804async function handle($: any, line: string) {
805  let frame: any
806  try {
807    frame = JSON.parse(line)
808  } catch {
809    return
810  }
811  const id = typeof frame?.id === 'string' ? frame.id : undefined
812  if (!id || seen.includes(id)) return
813  seen.push(id)
814  if (seen.length > SEEN_MAX) seen.shift()
815  switch (frame.kind) {
816    case 'ping':
817      await relay($, 'ModPong', id, {}, false)
818      break
819    case 'submit':
820      if (typeof frame.text === 'string' && frame.text) void submitPrompt($, id, frame.text)
821      break
822    case 'fill':
823      if (typeof frame.text === 'string' && frame.text) await fillPrompt($, id, frame.text)
824      break
825    case 'answer': {
826      const call = typeof frame.tool_use_id === 'string' ? frame.tool_use_id : ''
827      const answers = frame.answers && typeof frame.answers === 'object' ? frame.answers : undefined
828      const settle = holds.get(call)
829      if (settle && answers) {
830        holds.delete(call)
831        settle(answers)
832      } else {
833        // The person answered or refused first: the daemon settles on theirs.
834        await relay($, 'ModAnswer', 'nothing_held', { tool_use_id: call }, false)
835      }
836      break
837    }
838    default:
839      // A kind from a newer daemon than this mod: not ours to guess at.
840      break
841  }
842}
843
844/** One bridge, read to its end; resolves with its exit code. */
845async function bridgeOnce($: any, c: Config): Promise<number | null> {
846  let buf = ''
847  try {
848    const child = $.process.spawn({
849      argv: [c.bin, 'mod-bridge', '--sock', c.orchSock, '--session', c.session, '--speaks', SPEAKS.join(',')],
850    })
851    for await (const { stream, text } of child) {
852      if (stream !== 'stdout') continue
853      buf += text
854      let nl: number
855      while ((nl = buf.indexOf('\n')) >= 0) {
856        const line = buf.slice(0, nl)
857        buf = buf.slice(nl + 1)
858        if (line.trim()) await handle($, line)
859      }
860    }
861    return (await child.result).code
862  } catch {
863    return null
864  }
865}
866
867/** The bridge, for the session's life: respawned with backoff when it dies. */
868async function bridgeLoop($: any, c: Config) {
869  let backoff = BACKOFF_FIRST_MS
870  try {
871    for (;;) {
872      const born = await $.clock.now()
873      if ((await bridgeOnce($, c)) === BRIDGE_REFUSED_EXIT) {
874        bridge = 'refused'
875        return
876      }
877      if ((await $.clock.now()) - born >= HEALTHY_MS) backoff = BACKOFF_FIRST_MS
878      await $.clock.sleep(backoff)
879      backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
880    }
881  } catch {
882    // The host went (the module unloading): so does the bridge.
883  }
884  bridge = 'off'
885}
886
887/** The bridge, unless one runs or the daemon refused this session's for good. */
888function startBridge($: any, c: Config) {
889  if (bridge !== 'off') return
890  bridge = 'on'
891  void bridgeLoop($, c)
892}
893
894/**
895 * The `ModUsage` report's body (T-581): the turn's own fields as the engine
896 * gave them, and the main loop's rate-limit windows. Never the answer's
897 * text: the daemon reads counts, not words.
898 */
899export function usageBody(e: any, usage: unknown, limits?: unknown): Record<string, unknown> {
900  const body: Record<string, unknown> = { turnId: e?.turnId, reason: e?.reason, durationMs: e?.durationMs }
901  if (typeof e?.agentId === 'string') body.agentId = e.agentId
902  if (usage && typeof usage === 'object') body.usage = usage
903  if (Array.isArray(limits)) body.rateLimits = limits
904  return body
905}
906
907/**
908 * A turn's end, observed: its count and, for the main loop, the account's
909 * windows. `$.session.usage()` is read before the hook returns, while the
910 * event's dispatch stands (a `$` call fails with an abandoned one, T-594);
911 * a read that failed sends the count alone.
912 */
913async function turnComplete($: any, e: any, next: any) {
914  const result = await next(e)
915  const agent = typeof e?.agentId === 'string' ? e.agentId : undefined
916  let limits: unknown
917  if (!agent) {
918    try {
919      limits = (await $.session.usage())?.rateLimits
920    } catch {
921      limits = undefined
922    }
923  }
924  // The native road (T-657): the hook set's frame for this turn's end comes
925  // first, as `classic.Stop` came before the mod's report. A subagent's or a
926  // teammate's turn is its `SubagentStop`; the main loop's is a `Stop` with
927  // the task list, or a `StopFailure` with no class when the API ended it
928  // (the transcript's tail has the words), and nothing on an interrupt, for
929  // which the hook set's Stop never fires.
930  if (await nativeRoad($, 'turn.complete')) {
931    if (agent) {
932      const row = ledger.get(agent)
933      if (row && row.type === 'subagent') row.status = 'completed'
934      const kind = spawned.get(agent) ?? row?.teammateId?.split('@')[0] ?? row?.agent_type
935      const body: Record<string, unknown> = { hook_event_name: 'SubagentStop', agent_id: agent }
936      if (typeof kind === 'string') body.agent_type = kind
937      void relay($, 'SubagentStop', undefined, body, false)
938    } else if (e?.reason === 'error') {
939      void relay($, 'StopFailure', 'unknown', { hook_event_name: 'StopFailure', error: 'unknown', native: true }, false)
940    } else if (e?.reason !== 'aborted') {
941      let listed: unknown
942      try {
943        listed = await $.agent.list()
944      } catch {
945        listed = undefined
946      }
947      const body = { hook_event_name: 'Stop', stop_hook_active: false, background_tasks: backgroundTasks(listed) }
948      void relay($, 'Stop', undefined, body, false)
949    }
950  }
951  void relay($, 'ModUsage', String(e?.reason ?? 'answer'), usageBody(e, e?.usage ?? result?.usage, limits), false)
952  return result
953}
954
955export const register: Register = on => {
956  on('session.start', async ($, e, next) => {
957    const result = await next(e)
958    // `session.start` may come again in the same process (a `/clear`); one
959    // bridge serves them all. The read brings the tools and then the bridge
960    // up (`bringUp`), not awaited here: the daemon sends no word before the
961    // bridge's first poll, so the tools are listed before any turn it
962    // starts, and a session.start held by them is one more dispatch to lose.
963    started = true
964    const c = await settings($, 'session.start')
965    // The native road (T-657): the process's one `session.start` is the
966    // hook set's `SessionStart{startup}`; a resume the daemon knows from its
967    // own launch and keeps its own word. The id names the transcript, which
968    // the daemon finds under its projects root as the census does: the path
969    // is not derived here (past 200 characters the engine hashes its slug).
970    // One read of the variables for the event, so a failed one is read
971    // again by the next event alone (T-594).
972    if (c?.native) {
973      cwd = typeof (e as any)?.cwd === 'string' ? (e as any).cwd : cwd
974      let id: string | undefined
975      try {
976        id = await $.session.id()
977      } catch {
978        id = undefined
979      }
980      if (typeof id === 'string') seenId = id
981      void relay($, 'SessionStart', 'startup', startBody('startup', id, cwd), false)
982    }
983    return result
984  })
985
986  // ---- The native road (T-657), event by event. Each hook relays only
987  // under the switch and otherwise passes the event on untouched. Every
988  // `$` read is the hook's own (T-594).
989  on('session.end', async ($, e, next) => {
990    const reason = String((e as any)?.reason ?? 'other')
991    if (await nativeRoad($, 'session.end')) {
992      lastEnd = reason
993      const body: Record<string, unknown> = { hook_event_name: 'SessionEnd', reason }
994      if (typeof (e as any)?.sessionId === 'string') body.session_id = (e as any).sessionId
995      // Awaited, as the classic one is: the process may be gone before an
996      // unawaited relay runs.
997      if (SESSION_END_REASONS.includes(reason)) await relay($, 'SessionEnd', reason, body, true)
998    }
999    return next(e)
1000  })
1001  on('turn.start', async ($, e, next) => {
1002    void refreshTools($)
1003    if (await nativeRoad($, 'turn.start')) {
1004      let id: string | undefined
1005      try {
1006        id = await $.session.id()
1007      } catch {
1008        id = undefined
1009      }
1010      // A `/clear` fires no `session.start` (T-651): the conversation that
1011      // ended with `session.end{clear}` is followed by one whose id the
1012      // next turn reads. An in-session `/resume` is the same edge with the
1013      // end's own word.
1014      if (typeof id === 'string') {
1015        if (seenId !== undefined && id !== seenId) {
1016          const source = lastEnd === 'resume' ? 'resume' : 'clear'
1017          void relay($, 'SessionStart', source, startBody(source, id, cwd), false)
1018        }
1019        seenId = id
1020      }
1021      const text = (e as any)?.text
1022      for (const ended of taskNotified(text)) ledger.delete(ended)
1023      const body: Record<string, unknown> = { hook_event_name: 'UserPromptSubmit', prompt: typeof text === 'string' ? text : '' }
1024      if (typeof id === 'string') body.session_id = id
1025      void relay($, 'UserPromptSubmit', undefined, body, false)
1026    }
1027    return next(e)
1028  })
1029  // Every tool call, before and after: the hook set's `PreToolUse` (the
1030  // daemon reads the two dialog tools as dialogs and every other as the
1031  // session working) and its `PostToolUse` with the result as it came. No
1032  // `PostToolUse` for a refused call or a tool's error, for which the hook
1033  // set fires none either; a refused plan is the dialog's dismissal, said
1034  // as the question's is (`ModAnswer declined`); a question the board
1035  // answered is said by its `ModAnswer answered` alone.
1036  on('tool.call', async ($, e, next) => {
1037    if (!(await nativeRoad($, 'tool.call'))) return next(e)
1038    void relay($, 'PreToolUse', undefined, preToolUseBody(e), false)
1039    const r: any = await next(e)
1040    const { tool, tool_use_id: id, agentId } = e as any
1041    if (r?.deny !== undefined) return r
1042    if (r?.isError) {
1043      if (tool === 'ExitPlanMode' && typeof id === 'string' && typeof agentId !== 'string') {
1044        void relay($, 'ModAnswer', 'declined', planDeclinedBody(id, (e as any).plan), false)
1045      }
1046      return r
1047    }
1048    if (typeof id === 'string' && boardAnswered.delete(id)) return r
1049    const row = taskStarted(e, r?.result)
1050    if (row) keep(row)
1051    if (tool === 'TaskStop') taskStopped(e)
1052    void relay($, 'PostToolUse', undefined, postToolUseBody(e, r?.result), false)
1053    return r
1054  })
1055  on('agent.spawn', async ($, e, next) => {
1056    const r: any = await next(e)
1057    if ((await nativeRoad($, 'agent.spawn')) && typeof r?.agentId === 'string') {
1058      // A teammate's `agent_type` is its name, as the hook set gives it; the
1059      // row the `Stop` lists is keyed by the agent's own id, so the daemon's
1060      // registry closes the `SubagentStart` it opened under that id.
1061      const teammate = typeof r.teammateId === 'string' ? r.teammateId : undefined
1062      const kind = teammate ? teammate.split('@')[0] : String((e as any)?.subagentType ?? '')
1063      if (teammate) {
1064        keep({ id: r.agentId, type: 'teammate', status: 'running', description: String((e as any)?.description ?? ''), teammateId: teammate })
1065      } else {
1066        spawned.set(r.agentId, kind)
1067        if (spawned.size > LEDGER_MAX) spawned.delete(spawned.keys().next().value as string)
1068      }
1069      void relay($, 'SubagentStart', undefined, { hook_event_name: 'SubagentStart', agent_id: r.agentId, agent_type: kind }, false)
1070    }
1071    return r
1072  })
1073  // A task's end (T-691). A notification the engine delivers INTO a running
1074  // turn starts no turn, so a ledger read off `turn.start` alone kept the
1075  // row, and every later `Stop` listed a task long over: the card spun on an
1076  // idle seat. Every notification is a row the conversation keeps, whether
1077  // it opened a turn or joined one. Read, never rewritten (promise 3); off
1078  // the native road the ledger is empty and this removes nothing.
1079  on('session.append', async ($, e, next) => {
1080    for (const id of rowNotified(e)) ledger.delete(id)
1081    return next(e)
1082  })
1083  on('session.receive', async ($, e, next) => {
1084    if (await nativeRoad($, 'session.receive')) {
1085      // A teammate's idle notice (T-651): a peer delivery whose text is the
1086      // `idle_notification` JSON. Everything else is the conversation's.
1087      const origin = (e as any)?.origin
1088      if (origin?.kind === 'peer' && typeof origin.teammate === 'string' && typeof (e as any)?.agentId !== 'string') {
1089        let kind: unknown
1090        try {
1091          kind = JSON.parse(String((e as any)?.text ?? ''))?.type
1092        } catch {
1093          kind = undefined
1094        }
1095        if (kind === 'idle_notification') {
1096          const body: Record<string, unknown> = { hook_event_name: 'TeammateIdle', teammate_name: origin.teammate }
1097          const team = teamOf(origin.teammate)
1098          if (team !== undefined) body.team_name = team
1099          void relay($, 'TeammateIdle', undefined, body, false)
1100        }
1101      }
1102    }
1103    return next(e)
1104  })
1105  on('session.compact', async ($, e, next) => {
1106    // The hook set's order (T-651): `PreCompact`, `SessionStart{compact}`,
1107    // `PostCompact`, the same id throughout. A vetoed compaction fires the
1108    // first alone.
1109    const trigger = (e as any)?.trigger
1110    const agent = typeof (e as any)?.agentId === 'string' ? (e as any).agentId : undefined
1111    const relayed = (await nativeRoad($, 'session.compact')) && COMPACT_TRIGGERS.includes(trigger)
1112    if (relayed) void relay($, 'PreCompact', undefined, compactBody('PreCompact', trigger, agent), false)
1113    const r: any = await next(e)
1114    if (relayed && r?.skip === undefined) {
1115      if (!agent) {
1116        let id: string | undefined
1117        try {
1118          id = await $.session.id()
1119        } catch {
1120          id = undefined
1121        }
1122        void relay($, 'SessionStart', 'compact', startBody('compact', id, cwd), false)
1123      }
1124      void relay($, 'PostCompact', undefined, compactBody('PostCompact', trigger, agent), false)
1125    }
1126    return r
1127  })
1128
1129  // ---- The relay, event by event: the hook set's names, not `classic.*`,
1130  // which also delivers the verbose tier mesimon never registers. Silent
1131  // under the native road (T-657): such an account fires none of these,
1132  // and one that did would be relayed twice.
1133  on('classic.SessionStart', async ($, e, next) => {
1134    const source = (e as any).source
1135    if (SESSION_START_SOURCES.includes(source)) void relayClassic($, 'SessionStart', source, e, false)
1136    return next(e)
1137  })
1138  on('classic.SessionEnd', async ($, e, next) => {
1139    // Awaited: the process may be gone before an unawaited relay runs.
1140    const reason = (e as any).reason
1141    if (SESSION_END_REASONS.includes(reason)) await relayClassic($, 'SessionEnd', reason, e, true)
1142    return next(e)
1143  })
1144  on('classic.StopFailure', async ($, e, next) => {
1145    const error = (e as any).error
1146    if (STOP_FAILURE_MATCHERS.includes(error)) void relayClassic($, 'StopFailure', error, e, false)
1147    return next(e)
1148  })
1149  on('classic.PreToolUse', async ($, e, next) => {
1150    const tool = (e as any).tool
1151    if (PRE_TOOL_USE_TOOLS.includes(tool)) {
1152      const id = (e as any).tool_use_id
1153      const agent = typeof id === 'string' ? agents.get(id) : undefined
1154      if (typeof id === 'string') agents.delete(id)
1155      void relayClassic($, 'PreToolUse', undefined, preToolUseBody(e, agent), false)
1156    }
1157    return next(e)
1158  })
1159  // Noted before anything else runs for the call: which subagent it is.
1160  on('tool.call', { tool: PRE_TOOL_USE_TOOLS }, async ($, e, next) => {
1161    const { tool_use_id: id, agentId } = e as any
1162    if (typeof id === 'string' && typeof agentId === 'string') {
1163      agents.set(id, agentId)
1164      if (agents.size > AGENTS_MAX) agents.delete(agents.keys().next().value as string)
1165    }
1166    return next(e)
1167  })
1168  // ---- The gate (T-577): deny or nothing, local and static. Nothing is
1169  // asked of the daemon, so a dead one still refuses; the denial is reported
1170  // afterwards for the feed and may be lost. Bash is not judged: a command
1171  // string is no path (docs/USING.md).
1172  // A hook that threw would be skipped and the write would run, so a
1173  // decision that could not be made is a refusal.
1174  on('tool.call', { tool: GATE_TOOLS }, async ($, e, next) => {
1175    const path = gatePath(e)
1176    if (path === undefined) return next(e)
1177    let r: Roots | undefined
1178    let rule: string | undefined
1179    try {
1180      r = await gateRoots($)
1181      rule = r ? await guardedBy($, path, r) : 'no_roots'
1182    } catch {
1183      r = undefined
1184      rule = 'no_roots'
1185    }
1186    if (rule === undefined) return next(e)
1187    // The path, and nothing else: the one fact the feed's line carries.
1188    if (r) void relay($, 'GateDenied', rule, { file_path: path }, false)
1189    return { deny: denial(rule) }
1190  })
1191
1192  // ---- The tools (T-577): each call of a tool this session registered
1193  // goes to the daemon through `mesimon mcp --call`, the model's arguments
1194  // as they came (the envelope's own keys aside), and its answer comes
1195  // back. A hook that threw would be skipped and the model told it lacked a
1196  // permission, so nothing here throws: a call that could not be made is a
1197  // refusal in plain words.
1198  on('tool.call', { tool: /^mcp__mesimon__/ }, async ($, e, next) => {
1199    const { tool, tool_use_id, agentId, consent, ...args } = e as any
1200    void agentId