SLOPSHOPPER

drift-fork

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

newcommandtoaststatusmodeltimer
★ 1v0.1.0no licenseupdated 2026-10-04markusstrasser/agent-infra/mods/drift-fork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drift-fork
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /drift ⎿ drift-fork: on · every 10 turns · inject off ⎿ drift-fork: No drift checks yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 2 files
hooks/register.ts 124 lines
1import { 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}
124
types/index.d.ts 14 lines
1export 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