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…

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>hooks/register.ts 466 lines1// 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