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…

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.

<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.
v shows the branch's diff and m merges it, fast-forward only.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.
cd into a git repository and run mesimon.o and type a task as the title, for example Add a --version flag.shift+tab so the line under the title reads ⎇ worktree. The agent gets its own branch.shift+enter. The ticket is saved, an agent starts on it, and its card spins while it works.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.
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.

<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.

<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.

<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.

<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>
mesimon doctor prints fixes for you to apply; it never applies one itself.The promises in full, with every path mesimon writes. If you find mesimon breaking one, report it.
Apache-2.0. See LICENSE, NOTICE and TRADEMARK.md.
hooks/register.ts 1303 lines1// 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