1import { atom, read, update } from 'claude-code' 2import type { Register, EngineInterface } from 'claude-code' 3 4import type { Decision, Shown } from '../types' 5import { route } from './bridge.ts' 6 7const last = atom({ plugin: 'jevhooks', key: 'last' } as const, null) 8 9// The daemon gives up on Jev after 1.5 s; this is the mod's own limit on the 10// daemon, so a wedged daemon cannot hold a tool call or a turn's end. 11const DAEMON_ANSWERS_WITHIN_MS = 2500 12 13// The daemon's socket: the same path `jevhooks-daemon`'s paths.rs computes. 14async function socketPath($: EngineInterface): Promise<string> { 15 const state = (await $.env.get('XDG_STATE_HOME')) || `${await $.env.get('HOME')}/.local/state` 16 return `${state}/jevhooks/daemon.sock` 17} 18 19// One request to the daemon. Undefined when it cannot be reached or refuses: 20// the caller then behaves as if this plugin were not installed. 21async function daemon($: EngineInterface, method: 'GET' | 'POST', path: string, body?: unknown): Promise<unknown> { 22 try { 23 const response = await $.http.fetch(`http://jevhooks${path}`, { 24 method, 25 socketPath: await socketPath($), 26 headers: { 'content-type': 'application/json' }, 27 body: body === undefined ? undefined : JSON.stringify(body), 28 }) 29 if (!response.ok) { 30 $.ui.log(`jevhooks: daemon answered ${response.status} to ${path}: ${response.text}`, { to: 'debug' }) 31 return undefined 32 } 33 return JSON.parse(response.text) 34 } catch (err) { 35 $.ui.log(`jevhooks: no daemon for ${path}: ${String(err)}`, { to: 'debug' }) 36 return undefined 37 } 38} 39 40// What every event sent to the daemon carries beside its own fields. 41async function envelope($: EngineInterface, event: string, input: Record<string, unknown>) { 42 return { session: await $.session.id(), event, input, cwd: await $.session.cwd(), root: await $.session.root() } 43} 44 45// Sends a deciding event and waits for the verdict, at most DAEMON_ANSWERS_WITHIN_MS. 46async function decide( 47 $: EngineInterface, 48 event: string, 49 kind: Shown['kind'], 50 input: Record<string, unknown>, 51): Promise<Decision | undefined> { 52 const started = Date.now() 53 const late = $.clock.sleep(DAEMON_ANSWERS_WITHIN_MS).then(() => undefined) 54 const asked = envelope($, event, input).then((body) => daemon($, 'POST', '/decide', body)) 55 const decision = (await Promise.race([asked, late])) as Decision | undefined 56 if (decision === undefined) return undefined 57 $.ui.log(`jevhooks: ${event} ${decision.verdict} in ${Date.now() - started} ms (daemon ${decision.ms} ms)`, { to: 'debug' }) 58 await update($, last, () => ({ ...decision, kind })) 59 return decision 60} 61 62// Sends an event the daemon needs but does not answer. Never awaited by a hook. 63// Nothing waits on it, so nothing could handle a rejection: it never rejects. 64async function observe($: EngineInterface, event: string, input: Record<string, unknown>): Promise<void> { 65 try { 66 await daemon($, 'POST', '/observe', await envelope($, event, input)) 67 } catch (err) { 68 $.ui.log(`jevhooks: ${event} not reported: ${String(err)}`, { to: 'debug' }) 69 } 70} 71 72// `/jev`: the daemon's status and its latest decisions, as text. 73async function report($: EngineInterface): Promise<string> { 74 const status = (await daemon($, 'GET', '/status')) as 75 | { jev: string; model: string; decided: number; observed: number; detail: { line: string } } 76 | undefined 77 if (status === undefined) return 'jevhooks: the daemon is not running (it starts with the session; see ~/.local/state/jevhooks/daemon.log).' 78 const records = ((await daemon($, 'GET', '/recent?count=10')) ?? []) as Array<Record<string, unknown>> 79 const lines = records.map((record) => 80 record.record === 'decision' 81 ? ` ${String(record.verdict).padEnd(5)} ${record.ms} ms ${record.line} [${String(record.subject).replace(/\s+/g, ' ').slice(0, 60)}]` 82 : ` then it ${record.outcome === 'ran' ? 'ran' : record.outcome === 'failed' ? 'failed' : 'was denied'}`, 83 ) 84 return [ 85 `Jev ${status.jev} (${status.model}); ${status.decided} decided, ${status.observed} observed.`, 86 status.detail.line, 87 lines.length ? 'Latest decisions, oldest first:' : 'No decisions yet.', 88 ...lines, 89 ].join('\n') 90} 91 92export const register: Register = (on) => { 93 on('session.start', async ($, e, next) => { 94 await $.command.register({ name: 'jev', description: "Show jevhooks' status and its latest decisions" }) 95 return next(e) 96 }) 97 98 on('command.run', { command: 'jev' }, async ($) => ({ text: await report($) })) 99 100 // A Bash command is judged before it runs. Whatever sits beneath this hook 101 // (the user's own settings hooks) still runs, and the stricter answer wins. 102 on('classic.PreToolUse', async ($, e, next) => { 103 const command = e.tool === 'Bash' ? e.command : undefined 104 if (route({ event: 'PreToolUse', tool: e.tool, command }).role !== 'decide') return next(e) 105 const [decision, beneath] = await Promise.all([ 106 decide($, 'PreToolUse', 'command', { tool: e.tool, command, tool_use_id: e.tool_use_id }), 107 next(e), 108 ]) 109 if (decision === undefined || beneath.deny !== undefined) return beneath 110 if (decision.verdict === 'ask') return { ...beneath, allow: undefined, deny: undefined, ask: beneath.ask ?? decision.line } 111 if (decision.verdict === 'allow' && beneath.ask === undefined) return { ...beneath, ask: undefined, deny: undefined, allow: true } 112 return beneath 113 }) 114 115 // A turn's end is judged before it stops; a confident "not finished" sends 116 // the reason back so the turn goes on. 117 on('classic.Stop', async ($, e, next) => { 118 const [decision, beneath] = await Promise.all([ 119 decide($, 'Stop', 'turn end', { 120 stop_hook_active: e.stop_hook_active, 121 last_assistant_message: e.last_assistant_message, 122 background_tasks: e.background_tasks, 123 session_crons: e.session_crons, 124 }), 125 next(e), 126 ]) 127 if (decision?.verdict !== 'ask' || beneath.block !== undefined) return beneath 128 return { ...beneath, block: `${decision.line}. Finish what was asked, or say plainly what is stopping you.` } 129 }) 130 131 // Everything else: the Rust side says whether the daemon needs to hear of 132 // it, and if so it is sent without waiting. 133 on('classic.*', ($, e, next) => { 134 const event = next.event.slice('classic.'.length) 135 const fields = e as Record<string, unknown> 136 const tool = typeof fields.tool_name === 'string' ? fields.tool_name : undefined 137 const toolInput = fields.tool_input as { command?: unknown } | undefined 138 const command = typeof toolInput?.command === 'string' ? toolInput.command : undefined 139 if (route({ event, tool, command }).role === 'observe') { 140 void observe($, event, { prompt: fields.prompt, source: fields.source, tool_use_id: fields.tool_use_id }) 141 } 142 return next(e) 143 }) 144 145 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { 146 const shown = await read($, last) 147 if (e.props.hasSurvey || shown === null) return next(e) 148 const { Box, Text } = $.ui.resolve(e) 149 const color = shown.verdict === 'ask' ? 'yellow' : undefined 150 return ( 151 <Box> 152 <Text color={color} dimColor={color === undefined}> 153 {shown.kind}: {shown.line} · {shown.ms} ms · ${shown.session_usd.toFixed(4)} this session 154 </Text> 155 </Box> 156 ) 157 }) 158}