jevhooks.git / plugin / hooks / register.tsx
register.tsx158 lines · 7.2 KB · raw
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}