SLOPSHOPPER

mesimon-spike

T-573 research spike: measures Claude Code function hooks against mesimon's hook set, paste road and dialog scraping. Loaded only by MESIMON_MOD_DIR; never…

newrowsguardprompttoolprocess
★ 4v0.1.0Apache-2.0updated 2026-10-05amitozalvo/mesimon/crates/mesimon-daemon/mod-spike
A shopper browsing a rack in a slop shop
README

mesimon-spike: T-573's research mod

A throwaway Claude Code mod (a plugin of function hooks, Claude Code ≥ 2.1.287) that measures, on the real Claude Code, whether a mod can replace the ad hocs mesimon built to integrate with it: the generated hook set and mesimon hook, the paste road, the dialog scraping, the permission bridge, mesimon gate, the plan accept, the MCP shim and the cost reader. The measurements and the go/no-go are in docs/STALE-MAP.md under T-573; the migration plan is a note on the ticket. Nothing here ships: the daemon loads it only when MESIMON_MOD_DIR names this folder (a copy under the state dir), and nothing else does.

Files:

  • .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts — the mod. claude plugin validate . reports what it hooks, calls and reads.
  • hooks/register.test.ts — claude plugin test ., the hooks' shapes against the engine's own $.
  • wait.py — the stand-in for mesimon approve's wait (row 4): run by the mod inside $.process.run while a permission dialog is up, it waits for a decision file and prints it.
  • bridge.py — the daemon→mod direction of the bridge: spawned once per session by the mod, it tails a spool directory and prints each command as a line the mod reads as a stream. (The mod→daemon direction is $.process.run of the real mesimon hook per event; $.process.spawn's stdin is one string in 2.1.287.)
  • drive.py — the measurement driver: each scenario runs the real claude (Haiku, --setting-sources "", a scratch cwd, the trust dialog answered by key) in a private tmux server, sends prompts the way mesimon does, drops commands into the spool and waits on the mod's event log.
  • report.py — prints a session's event log as a timeline.

To measure again (cents, on the person's own Claude login):

P=~/.local/state/mesimon/<proj16>/mod-spike
rsync -a --exclude .claude-plugin/types --exclude tsconfig.json ./ "$P"/
python3 drive.py run --mod "$P" --out /tmp/spike-out coverage submit submit_midturn ask permission permit gate plan_result plan_allow plan_native tools relay load
python3 report.py /tmp/spike-out/ask/main/log

To load it on a board for a look (the mod observes into <state>/mod-log/<KEY>/ and refuses writes under .mesimon; it submits nothing and relays nothing unless its other MESIMON_MOD_* variables are set):

MESIMON_MOD_DIR="$P" cargo run -- daemon --repo <repo>
Source 1 files
hooks/register.ts 466 lines
1// T-573 research spike. A throwaway mod that OBSERVES every event mesimon's
2// generated hook set observes today, and tries each replacement the ticket
3// names, so each can be proven or refuted on the real Claude Code. It holds
4// no policy and ships to nobody: `MESIMON_MOD_DIR` loads it, nothing else.
5//
6// Doctrine it keeps even as a spike (README promise 3): it never calls
7// `$.session.append`, never attaches `context` on `prompt.submit`, never
8// hooks `prompt.compose`/`prompt.context`, and never rewrites a prompt's
9// words. Every prompt it submits is `asUser: true`.
10//
11// Environment (set by the driver or by the daemon's seam):
12//   MESIMON_MOD_LOG         a directory: one JSON file per event (the record)
13//   MESIMON_MOD_SPOOL       a directory `bridge.py` tails: commands down
14//   MESIMON_MOD_BRIEF_FILE  a file submitted `asUser` at session.start (row 2)
15//   MESIMON_MOD_GATE_BOARD  the board dir the gate refuses writes under (row 5)
16//   MESIMON_MOD_RELAY_SOCK  + MESIMON_MOD_RELAY_BIN + MESIMON_MOD_SESSION:
17//                           relay every classic event through the real
18//                           `mesimon hook` binary (row 1's shadow road)
19import type { Register } from 'claude-code'
20
21type Answer = { answers: Record<string, string>; response?: string }
22type Decision = 'allow' | 'ask' | 'deny'
23type Command =
24  | { kind: 'submit'; text: string; asUser?: boolean }
25  | { kind: 'answer'; answers: Record<string, string>; response?: string }
26  | { kind: 'plan'; mode: 'native' | 'result' | 'allow' }
27  | { kind: 'check'; tool: string; decision: Decision | null }
28  | { kind: 'usage' }
29  | { kind: 'tools' }
30  | { kind: 'ask'; question: string; options: string[] }
31  | { kind: 'hold_permits'; on: boolean }
32  | { kind: 'hold_check'; ms: number }
33
34const LONG = 4000
35
36/** Strings past LONG are cut; the record keeps their length. */
37const trim = (_key: string, value: unknown) =>
38  typeof value === 'string' && value.length > LONG
39    ? `${value.slice(0, LONG)}…[${value.length} chars]`
40    : value
41
42let seq = 0
43let logDir: Promise<string | undefined> | undefined
44const holds = new Map<string, (a: Answer) => void>()
45const checks = new Map<string, Decision>()
46let planMode: 'native' | 'result' | 'allow' = 'native'
47let holdPermits = false
48let holdCheckMs = 0
49let cwd = ''
50
51async function log($: any, event: string, data: unknown) {
52  logDir ??= $.env.get('MESIMON_MOD_LOG')
53  const dir = await logDir
54  if (!dir) return
55  const n = ++seq
56  const t = await $.clock.now()
57  const name = `${dir}/${String(n).padStart(5, '0')}-${event.replace(/[^A-Za-z0-9._-]/g, '_')}.json`
58  try {
59    await $.fs.write(name, JSON.stringify({ seq: n, t, event, data }, trim, 1))
60  } catch {
61    // Nowhere to say so.
62  }
63}
64
65async function submit($: any, text: string, asUser: boolean, why: string) {
66  const t0 = await $.clock.now()
67  await log($, 'prompt.submit.call', { why, asUser, chars: text.length })
68  try {
69    const r = await $.prompt.submit(asUser ? { text, asUser: true } : { text })
70    await log($, 'prompt.submit.resolved', { why, ms: (await $.clock.now()) - t0, origin: r.origin, drop: r.drop, chars: r.text?.length })
71  } catch (err) {
72    await log($, 'prompt.submit.rejected', { why, error: String(err) })
73  }
74}
75
76async function handle($: any, line: string) {
77  let cmd: Command
78  try {
79    cmd = JSON.parse(line)
80  } catch {
81    await log($, 'bridge.bad_line', line)
82    return
83  }
84  await log($, 'bridge.command', cmd)
85  switch (cmd.kind) {
86    case 'submit':
87      void submit($, cmd.text, cmd.asUser !== false, 'spool')
88      break
89    case 'answer': {
90      const first = holds.entries().next()
91      if (first.done) {
92        await log($, 'answer.nothing_held', cmd)
93      } else {
94        first.value[1]({ answers: cmd.answers, response: cmd.response })
95      }
96      break
97    }
98    case 'plan':
99      planMode = cmd.mode
100      break
101    case 'hold_permits':
102      holdPermits = cmd.on
103      break
104    case 'hold_check':
105      holdCheckMs = cmd.ms
106      break
107    case 'check':
108      if (cmd.decision) checks.set(cmd.tool, cmd.decision)
109      else checks.delete(cmd.tool)
110      break
111    case 'usage':
112      await log($, 'session.usage', await $.session.usage())
113      break
114    case 'tools':
115      await log($, 'tool.list', (await $.tool.list()).map((t: any) => t.name))
116      break
117    case 'ask':
118      try {
119        await log($, 'ui.ask.answer', await $.ui.ask(cmd.question, cmd.options))
120      } catch (err) {
121        await log($, 'ui.ask.rejected', String(err))
122      }
123      break
124  }
125}
126
127async function runBridge($: any, spool: string, root: string) {
128  const bridge = $.process.spawn({ argv: ['python3', `${root}/bridge.py`, spool] })
129  await log($, 'bridge.spawned', { spool })
130  let buf = ''
131  try {
132    for await (const { stream, text } of bridge) {
133      if (stream !== 'stdout') {
134        await log($, 'bridge.stderr', text)
135        continue
136      }
137      buf += text
138      let nl: number
139      while ((nl = buf.indexOf('\n')) >= 0) {
140        const line = buf.slice(0, nl)
141        buf = buf.slice(nl + 1)
142        if (line.trim()) void handle($, line)
143      }
144    }
145    await log($, 'bridge.ended', await bridge.result)
146  } catch (err) {
147    await log($, 'bridge.failed', String(err))
148  }
149}
150
151async function facts($: any) {
152  const out: Record<string, unknown> = {}
153  const take = async (k: string, f: () => Promise<unknown>) => {
154    try {
155      out[k] = await f()
156    } catch (err) {
157      out[k] = `ERR ${String(err)}`
158    }
159  }
160  await take('id', () => $.session.id())
161  await take('cwd', () => $.session.cwd())
162  await take('root', () => $.session.root())
163  await take('model', () => $.session.model())
164  await take('turns', () => $.session.turns())
165  await take('surfaces', () => $.session.surfaces())
166  await take('env.CLAUDE_CONFIG_DIR', () => $.env.get('CLAUDE_CONFIG_DIR'))
167  await take('env.HOME', () => $.env.get('HOME'))
168  await take('env.TMUX', () => $.env.get('TMUX'))
169  await take('env.TMUX_PANE', () => $.env.get('TMUX_PANE'))
170  return out
171}
172async function agents($: any) {
173  try {
174    return await $.agent.list()
175  } catch (err) {
176    return `ERR ${String(err)}`
177  }
178}
179
180export const register: Register = on => {
181  // ---- Row 1: every classic event, in-process, relayed through the real
182  // hook binary when asked (one `$.process.run` per event: `$.process.spawn`
183  // takes stdin as one string and closes it, so a long-lived up-channel does
184  // not exist in 2.1.287; see the block).
185  on('classic.*', async ($, e, next) => {
186    const name = next.event
187    const t0 = await $.clock.now()
188    await log($, name, e)
189    const sock = await $.env.get('MESIMON_MOD_RELAY_SOCK')
190    if (sock) {
191      const bin = (await $.env.get('MESIMON_MOD_RELAY_BIN')) ?? 'mesimon'
192      const session = (await $.env.get('MESIMON_MOD_SESSION')) ?? ''
193      const event = name.slice('classic.'.length)
194      const input: any = e
195      const reason =
196        event === 'SessionStart' ? input.source
197        : event === 'SessionEnd' ? input.reason
198        : event === 'StopFailure' ? input.error
199        : undefined
200      const argv = [bin, 'hook', '--sock', sock, '--session', session, '--event', event]
201      if (typeof reason === 'string') argv.push('--reason', reason)
202      void $.process
203        .run(argv, { stdin: JSON.stringify(e), timeoutMs: 5000 })
204        .then(
205          async r => log($, 'relay.done', { event, ms: (await $.clock.now()) - t0, exitCode: r.exitCode, stderr: r.stderr }),
206          async err => log($, 'relay.failed', { event, error: String(err) }),
207        )
208    }
209    return next(e)
210  })
211
212  // ---- Engine events the hook set has no name for.
213  on('session.start', async ($, e, next) => {
214    cwd = e.cwd
215    let version: unknown
216    try {
217      version = await $.session.version()
218    } catch (err) {
219      version = String(err)
220    }
221    await log($, 'session.start', { e, version, root: $.plugin.root, facts: await facts($), origin: next.origin })
222    // Row 7: a tool registered in-process, no MCP shim.
223    try {
224      const reg = await $.tool.register({
225        name: 'spike_ping',
226        description: 'Answers pong with the note given. A spike tool, nothing more.',
227        inputSchema: { type: 'object', properties: { note: { type: 'string' } } },
228      })
229      await log($, 'tool.register', reg)
230    } catch (err) {
231      await log($, 'tool.register.failed', String(err))
232    }
233    const started = await next(e)
234    await log($, 't651.session.start.result', started)
235    // Row 2: the launch brief, submitted as the person's words, no tty.
236    const brief = await $.env.get('MESIMON_MOD_BRIEF_FILE')
237    if (brief) {
238      const text = await $.fs.read(brief)
239      void submit($, text, true, 'launch')
240    }
241    const spool = await $.env.get('MESIMON_MOD_SPOOL')
242    if (spool) void runBridge($, spool, $.plugin.root)
243    return started
244  })
245  on('session.end', async ($, e, next) => {
246    await log($, 'session.end', { e, facts: await facts($), budget: next.budget })
247    return next(e)
248  })
249  on('session.compact', async ($, e, next) => {
250    await log($, 'session.compact', { trigger: e.trigger, agentId: e.agentId, messages: e.messages.length, instructions: e.instructions, keys: Object.keys(e), facts: await facts($) })
251    const r = await next(e)
252    await log($, 'session.compact.result', { keys: Object.keys(r as any), skip: (r as any).skip, messages: (r as any).messages?.length, tokensBefore: (r as any).tokensBefore, tokensAfter: (r as any).tokensAfter, facts: await facts($) })
253    return r
254  })
255  on('agent.spawn', async ($, e, next) => {
256    await log($, 'agent.spawn', e)
257    const r = await next(e)
258    await log($, 'agent.spawn.result', { r, agents: await agents($) })
259    return r
260  })
261  on('turn.start', async ($, e, next) => {
262    await log($, 'turn.start', { e: { ...e, text: e.text.slice(0, 200), chars: e.text.length }, facts: await facts($), agents: await agents($) })
263    const r = await next(e)
264    await log($, 'turn.start.result', r)
265    return r
266  })
267  on('turn.complete', async ($, e, next) => {
268    const r = await next(e)
269    await log($, 'turn.complete', { ...e, answer: String(e.answer).slice(0, 200), result: { ...(r as any), text: String((r as any)?.text ?? '').slice(0, 100) }, agents: await agents($), facts: await facts($) })
270    return r
271  })
272  // Row 8: what each request cost, as the API reported it.
273  on('turn.step', async function* ($, e, next) {
274    const r = yield* next(e)
275    await log($, 'turn.step', { turnId: e.turnId, index: e.index, model: e.model, agentId: e.agentId, usage: r.usage, stopReason: r.stopReason, tools: r.toolUses.map(t => t.name) })
276    return r
277  })
278  on('prompt.submit', async ($, e, next) => {
279    await log($, 'prompt.submit', { origin: e.origin, turnId: e.turnId, wait: e.wait, chars: e.text.length, head: e.text.slice(0, 80), context: e.context, attachments: e.attachments, keys: Object.keys(e) })
280    const r = await next(e)
281    await log($, 'prompt.submit.result', { origin: r.origin, drop: r.drop, chars: r.text?.length, context: r.context })
282    return r
283  })
284  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
285    await log($, 'ui.render.UserMessage', { requestId: e.requestId, origin: (e.props as any).origin, from: (e.props as any).from, text: String((e.props as any).text).slice(0, 160) })
286    return next(e)
287  })
288  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
289    await log($, 'ui.render.AskUserQuestion', { requestId: e.requestId, surface: e.surface, props: e.props })
290    return next(e)
291  })
292
293  // ---- Row 4: the permission verdict.
294  on('tool.check', async ($, e, next) => {
295    const t0 = await $.clock.now()
296    if (holdCheckMs > 0 && e.tool !== 'AskUserQuestion' && e.tool !== 'ExitPlanMode') {
297      const ms = holdCheckMs
298      holdCheckMs = 0
299      await log($, 't651.tool.check.hold', { tool: e.tool, tool_use_id: e.tool_use_id, ms })
300      try {
301        await $.process.run(['sleep', String(ms / 1000)], { timeoutMs: ms + 5000 })
302      } catch (err) {
303        await log($, 't651.tool.check.hold_failed', String(err))
304      }
305    }
306    const core = await next(e)
307    const want = checks.get(e.tool) ?? (e.tool === 'ExitPlanMode' && planMode === 'allow' ? 'allow' : undefined)
308    await log($, 'tool.check', { tool: e.tool, input: e.input, tool_use_id: e.tool_use_id, core, override: want, origin: next.origin, keys: Object.keys(e), ms: (await $.clock.now()) - t0, trace: (next as any).trace })
309    return want ? { decision: want, reason: `mesimon spike said ${want}` } : core
310  })
311
312  // ---- Row 4, the one-shot allow beside an open dialog: the in-process twin
313  // of `mesimon approve`. The hold sits inside a `$.process.run` (a `$` call
314  // in flight does not count against the hook's 10 s budget; a promise of
315  // the hook's own would), which waits for the daemon's decision (here: a
316  // file under the spool) and prints it, as the approve binary answers.
317  on('classic.PermissionRequest', async ($, e, next) => {
318    await log($, 't651.classic.PermissionRequest.agents', { agents: await agents($), facts: await facts($) })
319    const spool = await $.env.get('MESIMON_MOD_SPOOL')
320    if (!holdPermits || !spool || e.agent_id || e.tool_name === 'AskUserQuestion' || e.tool_name === 'ExitPlanMode') {
321      return next(e)
322    }
323    const t0 = await $.clock.now()
324    await log($, 'permit.hold', { tool: e.tool_name, input: e.tool_input, suggestions: e.permission_suggestions })
325    let r: { exitCode: number; stdout: string; stderr: string }
326    try {
327      r = await $.process.run(['python3', `${$.plugin.root}/wait.py`, `${spool}/permits`], { timeoutMs: 45000 })
328    } catch (err) {
329      await log($, 'permit.wait_failed', { ms: (await $.clock.now()) - t0, error: String(err), aborted: next.signal.aborted })
330      return next(e)
331    }
332    await log($, 'permit.wait_done', { ms: (await $.clock.now()) - t0, exitCode: r.exitCode, stdout: r.stdout, aborted: next.signal.aborted })
333    if (r.stdout.trim()) {
334      const decision = JSON.parse(r.stdout)
335      await log($, 'permit.answered', { decision, aborted: next.signal.aborted })
336      return { decision }
337    }
338    return next(e)
339  })
340
341  // ---- Row 3: the question, held until the daemon (here: the spool) or the
342  // person answers, whichever is first.
343  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
344    const t0 = await $.clock.now()
345    const id = e.tool_use_id
346    await log($, 'ask.call', { tool_use_id: id, agentId: e.agentId, questions: e.questions })
347    let settle: (a: Answer) => void = () => {}
348    const remote = new Promise<Answer>(resolve => { settle = resolve })
349    holds.set(id, settle)
350    const native = next(e).then(
351      r => ({ who: 'native' as const, r }),
352      err => ({ who: 'native_rejected' as const, err: String(err) }),
353    )
354    const spool = remote.then(a => ({ who: 'spool' as const, a }))
355    const first = await Promise.race([native, spool])
356    holds.delete(id)
357    const ms = (await $.clock.now()) - t0
358    if (first.who === 'spool') {
359      const result: any = { questions: e.questions, answers: first.a.answers }
360      if (first.a.response) result.response = first.a.response
361      await log($, 'ask.answered_by_spool', { ms, result })
362      void native.then(n => log($, 'ask.native_after_spool', n))
363      return { result }
364    }
365    if (first.who === 'native') {
366      await log($, 'ask.native', { ms, r: first.r })
367      return first.r
368    }
369    await log($, 'ask.native_rejected', { ms, err: first.err })
370    throw new Error(first.err)
371  })
372
373  // ---- Row 5: the gate, local and static. No daemon is asked.
374  on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
375    const tool = e.tool
376    const input: any = e
377    const path: unknown = input.file_path ?? input.notebook_path
378    // The guarded root is given, as `mesimon gate` gets `--deny-board`;
379    // the cwd's `.mesimon` stands in while nothing names one.
380    const board = (await $.env.get('MESIMON_MOD_GATE_BOARD')) ?? `${cwd}/.mesimon`
381    const root = board.endsWith('/') ? board : `${board}/`
382    if (typeof path === 'string' && (path.startsWith(root) || path === root.slice(0, -1))) {
383      await log($, 'gate.deny', { tool, path })
384      return { deny: `mesimon: ${path} is board state under .mesimon; the board writes it, an agent does not.` }
385    }
386    return next(e)
387  })
388
389  // ---- Row 6: the plan dialog.
390  on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
391    await log($, 'plan.call', { mode: planMode, e })
392    if (planMode === 'result') {
393      const input: any = e
394      const r = { result: { plan: input.plan ?? null, isAgent: false, filePath: input.planFilePath } }
395      await log($, 'plan.answered_by_mod', r)
396      return r as any
397    }
398    const r = await next(e)
399    await log($, 'plan.native', r)
400    return r
401  })
402
403  // ---- T-651: the native events that pass the security default, measured
404  // for the hook set's facts. Observe only: every hook returns next(e) as it
405  // came. `facts` is what `$.session` answers at that moment.
406  on('session.measure', async ($, e, next) => {
407    await log($, 't651.session.measure', e)
408    return next(e)
409  })
410  on('session.receive', async ($, e, next) => {
411    await log($, 't651.session.receive', { ...e, text: String(e.text).slice(0, 300) })
412    const r = await next(e)
413    await log($, 't651.session.receive.result', { keys: Object.keys(r as any), consumed: (r as any).consumed })
414    return r
415  })
416  // Every tool call: the envelope, the tool's own keys, and the result's shape.
417  on('tool.call', async ($, e, next) => {
418    const { tool, tool_use_id, agentId, consent, ...input } = e as any
419    const t0 = await $.clock.now()
420    await log($, 't651.tool.call', { tool, tool_use_id, agentId, consent, input, origin: next.origin })
421    let r: any
422    try {
423      r = await next(e)
424    } catch (err) {
425      await log($, 't651.tool.call.threw', { tool, tool_use_id, agentId, ms: (await $.clock.now()) - t0, error: String(err), aborted: next.signal.aborted })
426      throw err
427    }
428    const shape: any = { tool, tool_use_id, agentId, ms: (await $.clock.now()) - t0, keys: Object.keys(r ?? {}), deny: r?.deny, isError: r?.isError, isReadOnly: r?.isReadOnly }
429    shape.result = r?.result
430    shape.text = typeof r?.text === 'string' ? r.text.slice(0, 400) : r?.text
431    await log($, 't651.tool.call.result', shape)
432    return r
433  })
434  on('classic.Stop', async ($, e, next) => {
435    await log($, 't651.classic.Stop.agents', { agent_id: (e as any).agent_id, agents: await agents($), facts: await facts($) })
436    return next(e)
437  })
438  on('classic.SubagentStop', async ($, e, next) => {
439    await log($, 't651.classic.SubagentStop.agents', { agent_id: (e as any).agent_id, agents: await agents($), facts: await facts($) })
440    return next(e)
441  })
442  on('classic.SubagentStart', async ($, e, next) => {
443    await log($, 't651.classic.SubagentStart.agents', { agent_id: (e as any).agent_id, agents: await agents($), facts: await facts($) })
444    return next(e)
445  })
446  on('classic.TeammateIdle', async ($, e, next) => {
447    await log($, 't651.classic.TeammateIdle.agents', { agent_id: (e as any).agent_id, agents: await agents($), facts: await facts($) })
448    return next(e)
449  })
450  on('classic.Notification', async ($, e, next) => {
451    await log($, 't651.classic.Notification.agents', { agent_id: (e as any).agent_id, agents: await agents($), facts: await facts($) })
452    return next(e)
453  })
454  on('classic.StopFailure', async ($, e, next) => {
455    await log($, 't651.classic.StopFailure.agents', { agent_id: (e as any).agent_id, agents: await agents($), facts: await facts($) })
456    return next(e)
457  })
458
459  // ---- Row 7: serving the registered tool.
460  on('tool.call', { tool: 'mcp__mesimon-spike__spike_ping' }, async ($, e, next) => {
461    await log($, 'spike_ping.call', e)
462    return { result: `pong ${(e as any).note ?? ''}`.trim() } as any
463  })
464
465}
466