Wakes an idle Kevin with `kevin idle check` after kevin.idle_seconds (two hours by default) without a turn, in place of a CronCreate idle check, and draws the…

A Claude Code mod that wakes an idle Kevin (/kevin, a repo's request desk) so he can idle out without arming a timer of his own. In a session whose project root is a KEVIN-<repo>-<n> worktree, it notes when each main-loop turn starts and ends, and once he has gone kevin.idle_seconds without a turn it submits one prompt, kevin idle check. Kevin runs his own check (skill/kevin/SKILL.md, "Idle out") and decides; the wake's own turn restarts the clock, so an idle Kevin is woken once per idle period, never more. Every other session is left alone.
Install it on its own; the seven skill plugins do not need it:
/plugin install kevin-idle@bifrost
kevin.idle_seconds in the stark config, 7200 (two hours) by default, read at session start from config.json (then global/config.json) under STARK_ASSET_ROOT, else from ~/.claude/code-review/config.json, the link tools/asset_links.ts makes into the bifrost checkout. A missing file or a value that is not a whole number of seconds reads as 7200. A change reaches the next Kevin launched.
Between turns, a Kevin session shows the idle clock above the prompt, in the operator's status-line gauge style:
IDLE ▐████░░░░░░▌ 48m / 2h · wakes 16:12
The 10 cells fade from light red to dark red and round down, so the bar never reads full ahead of the wake (the wake's tick restarts it); wakes is local time. The band is hidden while a turn runs (the wake's own included) and while a survey shows, and restarts at <1m when the turn ends. It refreshes on the mod's one-minute tick and shows whole minutes, so a threshold is best set in whole minutes. Other sessions get no band.
It adds one section to the system prompt, in Kevin sessions only:
kevin-idle: the kevin-idle mod is armed in this session, at kevin.idle_seconds 7200. It sends you `kevin idle check` once you have gone 7200 seconds without a turn, so start no idle waker of your own.
With no such note (a Codex Kevin, or a Claude without this mod) Kevin starts tools/kevin_idle.ts --detach at launch instead, which wakes him by hermod at the same threshold.
claude plugin validate mods/kevin-idle
claude plugin test mods/kevin-idlehooks/register.tsx 193 lines1import { atom, read, update } from 'claude-code'
2import type { PromptComposeSection, Register, StateDollar } from 'claude-code'
3
4import type { Idle } from '../types'
5
6// The wake skill/kevin's "Idle out" answers; the waker tools/kevin_idle.ts sends the same text.
7export const WAKE_TEXT = 'kevin idle check'
8// tools/stark_config_lib.ts's DEFAULT_KEVIN, which this module cannot import.
9const DEFAULT_IDLE_SECONDS = 7200
10const POLL_MS = 60 * 1000
11const KEVIN_NAME = /^KEVIN-[A-Za-z0-9_-]+-\d+$/
12
13// The band's bar is the operator's status-line gauge (idavoll's statusline
14// bar): a rail, CELLS cells fading light to dark red, the rest dim.
15export const CELLS = 10
16const LIGHT = [248, 168, 168]
17const DARK = [139, 0, 0]
18const RAIL = '#d0d0d0'
19const EMPTY = '#8a8a8a'
20
21const idle = atom({ plugin: 'kevin-idle', key: 'idle' } as const, null)
22
23/** What tells Kevin the mod is armed and at what threshold, so he starts no waker of his own. */
24export function armed(idleSeconds: number): PromptComposeSection {
25 return {
26 id: 'kevin-idle:armed',
27 scope: 'session',
28 text:
29 `kevin-idle: the kevin-idle mod is armed in this session, at kevin.idle_seconds ${idleSeconds}. ` +
30 `It sends you \`${WAKE_TEXT}\` once you have gone ${idleSeconds} seconds without a turn, so start no ` +
31 'idle waker of your own.',
32 }
33}
34
35/** A Claude Kevin stands in a worktree named for his launch id, `KEVIN-<repo>-<n>`. */
36export function isKevinRoot(root: string): boolean {
37 return KEVIN_NAME.test(root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '')
38}
39
40/** The stark config's paths, as tools/asset_root_lib.ts's assetConfigPath probes them, flat first. */
41export function configPaths(assetRoot: string | undefined, home: string | undefined): string[] {
42 if (assetRoot) return [`${assetRoot}/config.json`, `${assetRoot}/global/config.json`]
43
44 return home ? [`${home}/.claude/code-review/config.json`] : []
45}
46
47/** `kevin.idle_seconds` from the config's text when it is a whole number of seconds, at least 1; else the default. */
48export function idleSeconds(text: string | undefined): number {
49 try {
50 const value: unknown = JSON.parse(text ?? '').kevin?.idle_seconds
51
52 return Number.isInteger(value) && (value as number) >= 1 ? (value as number) : DEFAULT_IDLE_SECONDS
53 } catch {
54 return DEFAULT_IDLE_SECONDS
55 }
56}
57
58/** Filled cells, rounded down: the bar is full only once the wake is due. */
59export function filled(state: Idle): number {
60 const share = (state.now - state.since) / (state.seconds * 1000)
61
62 return Math.min(CELLS, Math.max(0, Math.floor(share * CELLS)))
63}
64
65/** Cell `i`'s colour, `#rrggbb`, from LIGHT at the first cell to DARK at the last. */
66export function shade(i: number): string {
67 const hex = (a: number, b: number) =>
68 Math.round(a + ((b - a) * i) / (CELLS - 1))
69 .toString(16)
70 .padStart(2, '0')
71
72 return `#${LIGHT.map((a, k) => hex(a, DARK[k]!)).join('')}`
73}
74
75/** Whole minutes, at the status line's minute grain: `<1m`, `48m`, `1h05m`, `2h`. */
76export function duration(ms: number): string {
77 const minutes = Math.floor(ms / 60_000)
78 if (minutes < 1) return '<1m'
79 if (minutes < 60) return `${minutes}m`
80 const rest = minutes % 60
81
82 return `${Math.floor(minutes / 60)}h${rest === 0 ? '' : `${String(rest).padStart(2, '0')}m`}`
83}
84
85/** Local `HH:MM` of an epoch time. */
86export function clockTime(ms: number): string {
87 const at = new Date(ms)
88
89 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
90}
91
92/**
93 * Publishes the band's state; a write redraws the band. `value` is worked out
94 * as the write lands, and again on update's retry, so a retry never puts back
95 * a state a turn hook has since replaced. A failed write costs the band alone,
96 * never the wake.
97 */
98async function publish($: StateDollar, value: () => Idle | null): Promise<void> {
99 await update($, idle, value).catch(() => undefined)
100}
101
102export const register: Register = on => {
103 let isKevin = false
104 let seconds = DEFAULT_IDLE_SECONDS
105 let section: PromptComposeSection | null = null
106 let isBusy = false
107 // When the last wake went out: a wake that never became a turn (another hook
108 // refused it) goes again a period later, not never.
109 let wakeMs = 0
110 let lastMs = 0
111
112 // What the band draws as of `now`, read from the turn state when the write
113 // lands: nothing while a turn runs.
114 const idleAt = (now: number) => (): Idle | null =>
115 isBusy ? null : { since: Math.max(lastMs, wakeMs), seconds, now }
116
117 on('session.start', async ($, e, next) => {
118 if (!isKevinRoot(await $.session.root())) return next(e)
119 isKevin = true
120 const [assetRoot, home] = await Promise.all([$.env.get('STARK_ASSET_ROOT'), $.env.get('HOME')])
121 let text: string | undefined
122 for (const path of configPaths(assetRoot, home)) {
123 text = await $.fs.read(path).catch(() => undefined)
124 if (typeof text === 'string') break
125 }
126 seconds = idleSeconds(text)
127 section = armed(seconds)
128 lastMs = await $.clock.now()
129
130 $.clock.every(POLL_MS, async () => {
131 const now = await $.clock.now()
132 // A running turn's band is already hidden; turn.complete shows it again.
133 if (isBusy) return
134 if (now - Math.max(lastMs, wakeMs) < seconds * 1000) return publish($, idleAt(now))
135 // Once per idle period: the wake's own turn restarts the clock.
136 wakeMs = now
137 await publish($, idleAt(now))
138 await $.prompt.submit({ text: WAKE_TEXT })
139 })
140 await publish($, idleAt(lastMs))
141
142 return next(e)
143 })
144
145 on('turn.start', async ($, e, next) => {
146 isBusy = true
147 lastMs = await $.clock.now()
148 if (isKevin) await publish($, idleAt(lastMs))
149
150 return next(e)
151 })
152
153 on('turn.complete', async ($, e, next) => {
154 // A subagent's turn ends inside the main one; only the main loop's end is idle.
155 if (e.agentId === undefined) {
156 isBusy = false
157 lastMs = await $.clock.now()
158 if (isKevin) await publish($, idleAt(lastMs))
159 }
160
161 return next(e)
162 })
163
164 on('prompt.compose', async ($, e, next) => {
165 const composed = await next(e)
166
167 return section === null ? composed : { sections: [...composed.sections, section] }
168 })
169
170 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
171 const state = await read($, idle)
172 // The engine's isWorking too: a reload mid-turn restarts this module idle.
173 if (e.props.hasSurvey || e.props.isWorking || state === null) return next(e)
174
175 const { Text } = $.ui.resolve(e)
176 const n = filled(state)
177
178 return (
179 <Text key="kevin-idle">
180 <Text color={shade(0)}>IDLE</Text> <Text color={RAIL}>▐</Text>
181 {Array.from({ length: n }, (_, i) => (
182 <Text key={`cell-${i}`} color={shade(i)}>
183 █
184 </Text>
185 ))}
186 <Text color={EMPTY}>{'░'.repeat(CELLS - n)}</Text>
187 <Text color={RAIL}>▌</Text> {duration(state.now - state.since)} / {duration(state.seconds * 1000)}{' '}
188 <Text dimColor>· wakes {clockTime(state.since + state.seconds * 1000)}</Text>
189 </Text>
190 )
191 })
192}
193types/index.d.ts 9 lines1/** The idle clock the band draws: idle since `since`, as of `now`, both epoch ms; the wake is due `seconds` after `since`. */
2export type Idle = { since: number; seconds: number; now: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'kevin-idle': { idle: Idle | null }
7 }
8}
9