Every N turns, fork one cached side question: is the session drifting? Toast on drift, tokens logged

hooks/register.ts 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check } from '../types'
5
6// A semantic drift check a shell hook cannot afford: $.model.fork asks one
7// question over the session's own transcript, served from its prompt cache.
8// The verdict goes to the person (toast/status), not the model, unless
9// `/drift inject` is on. Every check's tokens land in
10// ~/.claude/drift-fork/<session>.json (eval-token-costs rule).
11
12const turns = atom({ plugin: 'drift-fork', key: 'turns' } as const, 0)
13const every = atom({ plugin: 'drift-fork', key: 'every' } as const, 10)
14const isOn = atom({ plugin: 'drift-fork', key: 'isOn' } as const, true)
15const inject = atom({ plugin: 'drift-fork', key: 'inject' } as const, false)
16const checks = atom({ plugin: 'drift-fork', key: 'checks' } as const, [])
17
18export const QUESTION = [
19 'Side check from the drift-fork mod, not from the user. Nobody in the main thread reads this reply.',
20 'Judge the conversation so far. Reply exactly `OK` if the work is on track.',
21 'Reply `DRIFT: <one sentence naming the evidence>` if any of these holds:',
22 "(1) the assistant is working on something other than the user's latest request;",
23 '(2) it has repeated the same failing action or approach three or more times;',
24 '(3) it is re-deriving or reversing a decision the user already made;',
25 '(4) it reported something done or verified with no evidence for it in the transcript.',
26 'No other text.',
27].join('\n')
28
29type $ = EngineInterface
30
31export function parseVerdict(text: string): { verdict: 'ok' | 'drift'; line: string } {
32 const t = text.trim()
33 const m = /^DRIFT:\s*([\s\S]+)$/i.exec(t)
34 if (m) return { verdict: 'drift', line: (m[1] ?? '').split('\n')[0]!.trim() }
35 return /^OK\b/i.test(t) ? { verdict: 'ok', line: '' } : { verdict: 'drift', line: `unparsed reply: ${t.slice(0, 120)}` }
36}
37
38let isChecking = false
39
40async function check($: $, turn: number): Promise<Check> {
41 isChecking = true
42 try {
43 const r = await $.model.fork({ prompt: QUESTION })
44 const at = new Date(await $.clock.now()).toISOString()
45 const usage =
46 'usage' in r
47 ? {
48 input: r.usage.input_tokens,
49 output: r.usage.output_tokens,
50 cacheRead: r.usage.cache_read_input_tokens,
51 cacheWrite: r.usage.cache_creation_input_tokens,
52 }
53 : null
54 const result: Check = r.isAnswered
55 ? { turn, at, usage, ...parseVerdict(r.text) }
56 : { turn, at, usage, verdict: 'unanswered', line: r.reason }
57 const all = await update($, checks, list => [...list, result].slice(-200))
58 const sessionId = await $.session.id()
59 const home = (await $.env.get('HOME')) ?? '/Users/alien'
60 await $.fs.write(`${home}/.claude/drift-fork/${sessionId}.json`, JSON.stringify({ session_id: sessionId, checks: all }, null, 2))
61 if (result.verdict === 'drift') {
62 $.ui.toast(`drift (turn ${turn}): ${result.line}`, { timeoutMs: 15_000 })
63 $.ui.status(`drift: ${result.line.slice(0, 60)}`)
64 if (await read($, inject)) {
65 await $.session.append({
66 message: {
67 type: 'user',
68 content: [{ type: 'text', text: `[drift-fork check at turn ${turn}, automated, not the user] ${result.line}` }],
69 },
70 })
71 }
72 } else {
73 $.ui.status(result.verdict === 'ok' ? undefined : `drift check failed: ${result.line}`)
74 }
75 return result
76 } finally {
77 isChecking = false
78 }
79}
80
81function summary(list: readonly Check[]): string {
82 if (list.length === 0) return 'No drift checks yet.'
83 const tok = list.reduce(
84 (n, c) => ({ out: n.out + (c.usage?.output ?? 0), fresh: n.fresh + (c.usage?.input ?? 0) + (c.usage?.cacheWrite ?? 0), cached: n.cached + (c.usage?.cacheRead ?? 0) }),
85 { out: 0, fresh: 0, cached: 0 },
86 )
87 const lines = list.slice(-8).map(c => `turn ${c.turn}: ${c.verdict}${c.line ? ` · ${c.line}` : ''}`)
88 return [...lines, `${list.length} checks · out ${tok.out} tok · uncached in ${tok.fresh} · cache read ${tok.cached}`].join('\n')
89}
90
91export const register: Register = on => {
92 on('session.start', async ($, e, next) => {
93 await $.command.register({
94 name: 'drift',
95 description: 'Drift checks: /drift · /drift now · /drift on|off · /drift every N · /drift inject on|off',
96 argumentHint: 'now | on | off | every N | inject on|off',
97 })
98 return next(e)
99 })
100
101 on('turn.complete', async ($, e, next) => {
102 const result = await next(e)
103 if (e.agentId !== undefined || e.reason !== 'answer' || !(await read($, isOn))) return result
104 const n = await update($, turns, t => t + 1)
105 if (n % (await read($, every)) === 0 && !isChecking) {
106 $.clock.after(1_000, () => void check($, n))
107 }
108 return result
109 })
110
111 on('command.run', { command: 'drift' }, async ($, e) => {
112 const a = e.args.trim().split(/\s+/).filter(Boolean)
113 if (a[0] === 'now') {
114 const c = await check($, await read($, turns))
115 return { text: `${c.verdict}${c.line ? `: ${c.line}` : ''}` }
116 }
117 if (a[0] === 'on' || a[0] === 'off') await update($, isOn, () => a[0] === 'on')
118 if (a[0] === 'every' && Number(a[1]) >= 3) await update($, every, () => Math.floor(Number(a[1])))
119 if (a[0] === 'inject' && (a[1] === 'on' || a[1] === 'off')) await update($, inject, () => a[1] === 'on')
120 const state = `${(await read($, isOn)) ? 'on' : 'off'} · every ${await read($, every)} turns · inject ${(await read($, inject)) ? 'on' : 'off'}`
121 return { text: `${state}\n${summary(await read($, checks))}` }
122 })
123}
124types/index.d.ts 14 lines1export type Check = {
2 turn: number
3 at: string
4 verdict: 'ok' | 'drift' | 'unanswered'
5 line: string
6 usage: { input: number; output: number; cacheRead: number; cacheWrite: number } | null
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'drift-fork': { turns: number; every: number; isOn: boolean; inject: boolean; checks: Check[] }
12 }
13}
14