Keeps the prompt cache warm with a periodic ping for sessions with a large context, counts subagent work as activity, never blocks a prompt a human did not…

Bun Warmer is a mod for Claude Code users who take breaks during long sessions. Claude Code caches your conversation for 60 minutes. After a longer break, your next message re-writes the whole context at the cache-write rate, which is slow and costly. Bun Warmer sends a small ping after 50 minutes of quiet so the cache stays warm, for up to 3 hours after your last prompt. It also flags subagents that go silent.
Inspired by cache-tax by Karan Bansal (MIT). Bun Warmer is written from scratch; none of its code is copied.
/ and runs mv, rm and test, so it does not run on Windows.python3 on your PATH for the transcript pulse (a small script that reads the session's local transcript files to confirm a subagent is still writing; bin/session-pulse.py, standard library only). Without it the stall watchdog still works from events alone.git clone https://github.com/eugeniawang/bun-warmer.git /path/to/bun-warmer
/path/to/bun-warmer is a placeholder used throughout this README. Replace it with the absolute path where you cloned the repo, for example /Users/<your-username>/code/bun-warmer on macOS or /home/<your-username>/bun-warmer on Linux; use an absolute path, not ~ or a relative one. If the path is wrong, the mod does not load and /bunwarm does not respond; if the mod loads but cannot find its pulse script, the state file shows pulse.reason as script-missing, and a plugin folder that is not absolute is listed in configWarnings.
claude --plugin-dir /path/to/bun-warmer
CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Use an absolute path. The value is a colon-joined list, so if you already load other folder mods, keep them: setting the variable to Bun Warmer's folder alone unloads every other folder mod that was in it. Read the current value first, then append:{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "<existing folders, colon-joined>:/path/to/bun-warmer" } }
/bunwarm. It answers bun-warmer on, ... left. You can also check that ~/.claude/bun-warmer/<session-id>.json exists.To uninstall, remove the folder from CLAUDE_CODE_PLUGIN_DIRS (or stop passing --plugin-dir) and start a new session.
Bun Warmer and cache-tax both ping, so load only one. Turn cache-tax off before you load Bun Warmer, for example claude plugin disable cache-tax@claude-code-mods (use the id claude plugin list shows for your install). To switch back, remove Bun Warmer and run claude plugin enable with the same id.
Bun Warmer arms itself when a session starts and when you type a prompt. You do not need to do anything else.
Slash command:
| Command | What it does |
|---|---|
/bunwarm | Shows status: time left in the window, failed or refused pings, pings skipped for quiet hours |
/bunwarm off | Stops Bun Warmer for this session |
/bunwarm on | Starts a fresh window for this session |
The status line shows the state, the time left in the window, and what happens next:
bun-warmer · idle · 1h30m left · waiting for the first turn
bun-warmer · warm · 1h25m left · ping in 50m
bun-warmer · cold · 20m left · cache cold, waiting for activity
idle means no turn has happened yet.warm means there was activity or a successful ping in the last 60 minutes.cold means 60 minutes have passed with no activity or successful ping.The last part can also read quiet hours, pings resume 06:00, context under 50,000 tokens, not pinging, or context size unknown, not pinging. A failed ping adds last ping FAILED: <reason>, and a silent subagent adds STALLED: <id> <N>m. When the mod is off, the line reads bun-warmer off: <reason>.
An environment variable beats the matching config menu value. The config menu fields (intervalMin, windowMin, minTokens, guard) appear in the Claude Code plugin config menu; for a folder-loaded plugin they are stored in settings under pluginConfigs, keyed bun-warmer@inline.
| Setting | Default | How to set it |
|---|---|---|
| Ping interval | 50 minutes, capped at 55 | env BUN_WARMER_INTERVAL_MIN, or config intervalMin |
| Keep-warm window | 180 minutes (3 hours) | env BUN_WARMER_WINDOW_HOURS (hours), else env BUN_WARMER_WINDOW_MIN, else config windowMin |
| Hard cap on window extensions | 12 hours | env BUN_WARMER_MAX_HOURS |
| Minimum context to ping or warn | 50,000 tokens | env BUN_WARMER_MIN_TOKENS, or config minTokens |
| Cold-send guard (what happens when you type into a cold cache) | warn | config guard: warn or refuse |
| First active hour | 6 | env BUN_WARMER_ACTIVE_START (0-23) |
| First quiet hour | 23 | env BUN_WARMER_ACTIVE_END (0-23, exclusive) |
| Quiet hours | on | env BUN_WARMER_QUIET_HOURS=off pings around the clock, still inside the window |
| Stall threshold | 20 minutes | env BUN_WARMER_STALL_MIN |
| Stall check interval | 5 minutes | env BUN_WARMER_STALL_CHECK_MIN |
| Pulse script path | <plugin folder>/bin/session-pulse.py | env BUN_WARMER_PULSE (absolute path ending in .py) |
Allowed ranges: window 1 to 720 minutes (BUN_WARMER_WINDOW_HOURS 1/60 to 12), ping interval 5 to 240 minutes (then capped at 55), hard cap 1 to 48 hours, minimum tokens 0 to 2,000,000, stall threshold 1 to 720 minutes, stall check interval 1 to 60 minutes, active hours whole numbers 0 to 23. A value outside its range, or one that is not a number, is ignored and the next source or the default applies. The variable's name (never its value) is added to configWarnings in the state file. A value that is clamped, such as an interval above 55, also adds a line there.
Active hours: a start greater than the end wraps midnight (22 and 6 means active from 22:00 to 05:59). A start equal to the end means no restriction.
After the ping interval passes with no activity, Bun Warmer sends a short, tool-less ping through $.model.fork. The ping reads the cache, which resets its 60-minute lifetime. Pings continue for the window after your last typed prompt.
Bun Warmer never pings:
$.session.usage() after each response);A ping that fails, is refused, or finds the cache already gone is recorded as failed in the state file and the status line. It is never reported as a success. A ping's own model call does not count as activity, so pings never extend the window.
Subagent turns count as activity. When the window ends while a subagent is still running, or turns arrived within the last interval, the window is extended instead of closed. Agents with status pending or waiting do not extend it, because an abandoned one could wait forever. Extensions stop at the hard cap (default 12 hours after the window started); at the cap Bun Warmer turns itself off.
The cache counts as warm when there was activity or a successful ping within the last 60 minutes; a failed ping does not count. When you type a prompt into a session whose cache has gone cold, the guard notes how many tokens the send will re-write. In the default warn mode it only logs that note. In refuse mode it drops your first send once with that note; send again to go ahead. It drops only once per cold period: after the next activity or successful ping, a later cold period is guarded again. The guard only looks at prompts a person typed (origins composer and bridge). Subagent returns, task notifications, scheduled prompts, SDK and plugin messages always pass through. Bun Warmer does not know model prices, so it shows tokens, not dollars.
Quiet hours use the local time of the machine Claude Code runs on, including daylight saving time. A ping that comes due during quiet hours is skipped and counted in skippedQuiet; it is not a failure. In practice the cache goes cold overnight, and the first prompt you type in the morning pays one re-write. Quiet hours are an extra limit on top of the window, never a replacement: an idle session started at 07:00 pings at 07:50 and stops when its window ends at 08:30.
/clear, resume and compaction/clear and an in-process resume both reset everything Bun Warmer knows about the old session, and a ping that was in flight when that happened is discarded. They differ in what comes next:
/clear leaves the old session's state file with armed: false and note: "cleared". The mod stays off until you type a prompt, which arms a new window under the new session id./resume (typed inside a running Claude Code) does not fire the plugin's session.start, because that hook fires once per process. The old state file is left with armed: false, and the mod stays off until you type a prompt, which arms a new window under the resumed session id.claude --resume (or claude --continue) does fire session.start, so the mod arms right away, without waiting for a prompt. The old session's state file is left with armed: false and note: "session ended".Compaction clears the remembered context size until the next response, so the guard never acts on the old count.
While a session has subagents, a second timer checks $.agent.list() every 5 minutes. This call is local and costs no tokens. A running subagent that has produced no events and has no tool call in flight for 20 minutes is flagged as stalled. A tool call that is still running counts as activity for as long as it runs.
The transcript pulse is a second signal. On each check that finds at least one subagent with status running, Bun Warmer runs python3 <plugin folder>/bin/session-pulse.py <session-id> (no shell, 10-second timeout). The script reads the tail of the session's transcript files and reports how long each subagent's transcript has been idle. A subagent is flagged only when both the events and the transcript say it has been quiet longer than the threshold. If the pulse is not available, events decide alone and the state file records why. script-missing is recorded only after such a check, so it does not appear while no subagent is running. See bin/README-session-pulse.md for the script's output.
Each stall is reported once. Bun Warmer adds it to the state file and the status line, and sends one message to the main conversation through $.prompt.submit. The message is fixed text plus a sanitized agent id, an allowlisted agent type, the minutes silent and the local time. It tells the model to check the agent in its own Agent list and not to stop or respawn it on this notice alone. The agent's free-text description is never put in the message, because it can contain instructions. Pulse output is treated as untrusted. Its string ids are used only to match the agent ids the engine reports and are never shown; its numbers and booleans drive the stall decision.
The message is sent only while the main conversation is idle. While a main turn is running, the message waits until that turn ends. The watchdog never stops or kills anything.
Each session writes ~/.claude/bun-warmer/<session-id>.json on every arm, disarm, ping and activity event. The file is for you and your scripts to read; nothing in Bun Warmer depends on it. It is written to a temporary file and moved into place, so a reader never sees half a file.
{
"sessionId": "…",
"writtenAt": "…", "writtenAtEpoch": 0, "writtenAtEpochMs": 0,
"armed": true,
"armedAt": "…", "armedAtEpoch": 0,
"windowStartAt": "…", "windowStartAtEpoch": 0,
"lastPingAt": "…", "lastPingAtEpoch": 0,
"lastActivityAt": "…", "lastActivityAtEpoch": 0,
"expiresAt": "…", "expiresAtEpoch": 0,
"refusals": 0,
"guardDrops": 0,
"quietHours": { "enabled": true, "activeStartHour": 6, "activeEndHour": 23 },
"skippedQuiet": 0,
"lastSkippedAt": null, "lastSkippedAtEpoch": null,
"lastPing": { "at": "…", "atEpoch": 0, "ok": true, "reason": null, "cacheReadTokens": 0, "cacheWriteTokens": 0 },
"intervalMin": 50,
"windowMin": 180, "windowHours": 3,
"minTokens": 50000, "contextTokens": 200000, "skippedSmall": 0,
"pulse": { "available": true, "reason": null, "checkedAt": "…", "checkedAtEpoch": 0, "agentsSeen": 1 },
"maxHours": 12,
"configWarnings": [],
"note": null,
"stallMin": 20,
"stallCheckMin": 5,
"stalledAgents": [
{ "id": "…", "description": "…", "type": "general-purpose", "minutesSilent": 22, "firstFlaggedAt": "…", "firstFlaggedAtEpoch": 0, "messageSent": true }
]
}
Notes for readers of the file:
lastActivityAt for real activity. Pings move lastPingAt only.null until the event has happened. contextTokens is null while the size is unknown.armed is false after the window ends, /bunwarm off, /clear or session end, and note says which.refusals counts pings that failed or were refused. guardDrops counts prompts dropped in refuse mode. skippedSmall counts pings skipped because the context was too small or its size unknown.messageSent is null while a stall message waits for an idle main conversation, and false if sending failed.writtenAtEpochMs. Treat a file older than the ping interval as out of date.File name: a UUID session id is used as is. Any other character outside A-Z a-z 0-9 _ - becomes % plus two uppercase hex digits per UTF-8 byte (a/b becomes a%2Fb).
If a write fails, the old file is kept and <session-id>.error.json is written beside it with the error and the time. The next successful write removes it. If the error file cannot be written either, Bun Warmer tries to remove the old state file so you do not read a stale one as current. If that fails too, the status line says STATE FILE NOT WRITTEN, and only the age of writtenAtEpochMs shows the file is stale.
/bunwarm off or /clear in that instant, one stall message can still arrive late. The message starts with the time it was sent and says to ignore it if the mod was turned off or the subagent has finished.$.agent.list() gives to a transcript file name. If your Claude Code version names the files differently, or the match is ambiguous, the pulse cannot confirm or clear a stall for that subagent. Events alone then decide, so a subagent that is still writing to its transcript can be flagged as stalled.$.agent.list() is unavailable or fails, the stall check for that pass is skipped and nothing is flagged, and a running subagent does not extend the window when it ends.$.agent.list() or in the transcript layout, so the watchdog cannot see them.claude plugin validate .
claude plugin test .
TZ=UTC claude plugin test .
python3 -m unittest discover -s tests/pulse
The tests build times from local calendar fields, so run them under more than one time zone.
License: not yet chosen.
hooks/register.ts 983 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MIN = 60 * 1000
4const HOUR = 60 * MIN
5const DEFAULT_MAX_HOURS = 12
6const COLD_AFTER_MS = 60 * MIN
7const MAX_INTERVAL_MIN = 55
8const DEFAULT_MIN_TOKENS = 50000
9const DEFAULT_WINDOW_MIN = 180
10const PULSE_TIMEOUT_MS = 10000
11const PING_PROMPT = 'Reply with the single word: warm'
12const DEFAULT_ACTIVE_START = 6
13const DEFAULT_ACTIVE_END = 23
14// Allowed range of each numeric setting; a value outside it is ignored with a recorded warning.
15const RANGE = {
16 window: [1, 720], interval: [5, 240], maxHours: [1, 48], minTokens: [0, 2_000_000], stall: [1, 720], stallCheck: [1, 60],
17} as const
18
19/** Prompt origins that are a person typing. Everything else (subagent returns, task notifications, peers, schedules, plugins, the SDK) is never blocked. */
20const HUMAN_ORIGINS = ['composer', 'bridge']
21
22type StalledAgent = { id: string; description: string; type: string; minutesSilent: number; firstFlaggedAt: number; messageSent: boolean | null }
23
24/** What the last stall check learned from the transcript pulse. Only our own fixed reason words, never text taken from the pulse. */
25type PulseRecord = { available: boolean; reason: string | null; checkedAt: number; agentsSeen: number }
26
27type PingResult = { at: number; ok: boolean; reason: string | null; read: number | null; write: number | null; model?: string | null; usd?: number | null }
28
29// $ per million tokens, [cache read, 1h cache write, output], list prices September 2026 (same table cache-tax uses).
30// Longer family names first: a model id matches the first row it contains.
31const PRICES: Array<[string, number, number, number]> = [
32 ['fable-5-1', 0.25, 20, 50],
33 ['fable-5', 1, 20, 50],
34 ['opus-5', 0.5, 10, 25],
35 ['opus-4', 0.5, 10, 25],
36 ['sonnet-5', 0.2, 4, 10],
37 ['sonnet', 0.3, 6, 15],
38 ['haiku', 0.1, 2, 5],
39]
40
41/** What one ping cost: cache read at the read rate, cache write at the write rate, plain input at half the write rate, output at the output rate. Null when the model is unknown. */
42export function pingUsd(model: string | null, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number | null {
43 const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
44 const row = PRICES.find(([family]) => m.includes(family))
45 if (!row) return null
46 const [read, write, output] = row.slice(1) as [number, number, number]
47 return (u.cache_read_input_tokens * read + u.cache_creation_input_tokens * write + u.input_tokens * write / 2 + u.output_tokens * output) / 1e6
48}
49
50type State = {
51 sid: string
52 armed: boolean
53 armedAt: number
54 windowStartAt: number
55 lastPingAt: number
56 /** When the last ping that found the cache warm went out. 0 when none. A failed ping refreshes nothing, so it never sets this. */
57 lastWarmPingAt: number
58 lastActivityAt: number
59 expiresAt: number
60 refusals: number
61 guardDrops: number
62 lastPing: PingResult | null
63 /** The full status text as last rendered, its state word (off, idle, warm, cold) and the next ping moment (0 when none). Kept for the state file; the UI is not told. */
64 statusText: string
65 statusState: string
66 nextPingAt: number
67 ctx: number
68 intervalMs: number
69 windowMs: number
70 /** Context size at or above which a ping goes out and the cold-cache warning shows. */
71 minTokens: number
72 maxMs: number
73 guard: 'warn' | 'refuse'
74 ackedAt: number
75 note: string | null
76 stallMs: number
77 stallCheckMs: number
78 stalled: StalledAgent[]
79 /** Quiet hours: pings go out only while the machine's local hour is in [activeStart, activeEnd). Off: pings run around the clock (still bounded by the window). */
80 quietEnabled: boolean
81 activeStart: number
82 activeEnd: number
83 skippedQuiet: number
84 lastSkippedAt: number
85 /** No ping before this time (the end of the quiet stretch a skip happened in). 0 when none. */
86 notBefore: number
87 /** Pings not sent because the context was below minTokens or its size was not yet known. */
88 skippedSmall: number
89 pulse: PulseRecord | null
90 /** Settings that were out of range or not usable at session start, each with the default that replaced it. Our own words plus the variable name, never a raw value. */
91 warnings: string[]
92}
93
94/** ISO 8601 in the machine's local zone with its offset, e.g. 2026-10-14T22:39:00-07:00; with `withMs`, 2026-10-14T22:39:00.123-07:00. Null for an unset time. */
95export function isoLocal(ms: number, withMs = false): string | null {
96 if (!ms) return null
97 const d = new Date(ms)
98 const off = -d.getTimezoneOffset()
99 const sign = off >= 0 ? '+' : '-'
100 const abs = Math.abs(off)
101 const p = (n: number, w = 2) => String(n).padStart(w, '0')
102 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}${withMs ? '.' + p(d.getMilliseconds(), 3) : ''}${sign}${p(Math.floor(abs / 60))}:${p(abs % 60)}`
103}
104
105function epoch(ms: number): number | null {
106 return ms ? Math.floor(ms / 1000) : null
107}
108
109export function stateFile(s: State, writtenAt: number): Record<string, unknown> {
110 return {
111 sessionId: s.sid,
112 writtenAt: isoLocal(writtenAt, true), writtenAtEpoch: epoch(writtenAt), writtenAtEpochMs: writtenAt,
113 armed: s.armed,
114 armedAt: isoLocal(s.armedAt), armedAtEpoch: epoch(s.armedAt),
115 windowStartAt: isoLocal(s.windowStartAt), windowStartAtEpoch: epoch(s.windowStartAt),
116 lastPingAt: isoLocal(s.lastPingAt), lastPingAtEpoch: epoch(s.lastPingAt),
117 lastActivityAt: isoLocal(s.lastActivityAt), lastActivityAtEpoch: epoch(s.lastActivityAt),
118 expiresAt: isoLocal(s.expiresAt), expiresAtEpoch: epoch(s.expiresAt),
119 refusals: s.refusals,
120 guardDrops: s.guardDrops,
121 lastPing: s.lastPing && {
122 at: isoLocal(s.lastPing.at), atEpoch: epoch(s.lastPing.at), ok: s.lastPing.ok, reason: s.lastPing.reason,
123 cacheReadTokens: s.lastPing.read, cacheWriteTokens: s.lastPing.write,
124 model: s.lastPing.model ?? null, costUsd: s.lastPing.usd ?? null,
125 },
126 statusText: s.statusText, statusState: s.statusState,
127 nextPingAt: isoLocal(s.nextPingAt), nextPingAtEpoch: epoch(s.nextPingAt),
128 intervalMin: s.intervalMs / MIN,
129 windowMin: s.windowMs / MIN,
130 windowHours: s.windowMs / HOUR,
131 minTokens: s.minTokens,
132 contextTokens: s.ctx || null,
133 skippedSmall: s.skippedSmall,
134 configWarnings: s.warnings,
135 pulse: s.pulse && { available: s.pulse.available, reason: s.pulse.reason, checkedAt: isoLocal(s.pulse.checkedAt), checkedAtEpoch: epoch(s.pulse.checkedAt), agentsSeen: s.pulse.agentsSeen },
136 maxHours: s.maxMs / HOUR,
137 note: s.note,
138 stallMin: s.stallMs / MIN,
139 stallCheckMin: s.stallCheckMs / MIN,
140 quietHours: { enabled: s.quietEnabled, activeStartHour: s.activeStart, activeEndHour: s.activeEnd },
141 skippedQuiet: s.skippedQuiet,
142 lastSkippedAt: isoLocal(s.lastSkippedAt), lastSkippedAtEpoch: epoch(s.lastSkippedAt),
143 stalledAgents: s.stalled.map(a => ({
144 id: safeId(a.id), description: a.description, type: safeType(a.type), minutesSilent: a.minutesSilent,
145 firstFlaggedAt: isoLocal(a.firstFlaggedAt), firstFlaggedAtEpoch: epoch(a.firstFlaggedAt), messageSent: a.messageSent,
146 })),
147 }
148}
149
150function fmtDur(ms: number): string {
151 const total = Math.max(0, Math.round(ms / 60000))
152 const h = Math.floor(total / 60)
153 const m = total % 60
154 return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
155}
156
157/** An hour 0-23 from an environment string; anything else (unset, empty, 6.5, 24, text) is the default. */
158export function hourSetting(text: string | undefined, fallback: number): number {
159 if (text === undefined || !/^\s*\d{1,2}\s*$/.test(text)) return fallback
160 const n = Number(text)
161 return n >= 0 && n <= 23 ? n : fallback
162}
163
164/** Only the exact word `off` (any case) disables the schedule. */
165export function quietHoursOff(text: string | undefined): boolean {
166 return text !== undefined && text.trim().toLowerCase() === 'off'
167}
168
169/**
170 * True when the local hour of `ms` falls in [start, end) of the machine's local clock, read with the Date local getters.
171 * start > end wraps midnight (22 to 6 is 22:00-23:59 and 00:00-05:59); start === end is no restriction at all.
172 */
173export function inActiveHours(ms: number, start: number, end: number): boolean {
174 if (start === end) return true
175 const h = new Date(ms).getHours()
176 return start < end ? h >= start && h < end : h >= start || h < end
177}
178
179/** The first moment at or after `ms` that is inside active hours. Built from local calendar fields, so a DST change moves it with the wall clock. */
180export function nextActiveMoment(ms: number, start: number, end: number): number {
181 if (inActiveHours(ms, start, end)) return ms
182 const d = new Date(ms)
183 let t = new Date(d.getFullYear(), d.getMonth(), d.getDate(), start, 0, 0, 0).getTime()
184 if (t <= ms) t = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, start, 0, 0, 0).getTime()
185 // A start hour skipped by a spring-forward lands in the next hour; step on until a moment is really active.
186 for (let i = 0; i < 48 && !inActiveHours(t, start, end); i++) t += HOUR
187 return t
188}
189
190function isQuiet(ms: number): boolean {
191 return s.quietEnabled && !inActiveHours(ms, s.activeStart, s.activeEnd)
192}
193
194function hhmm(ms: number): string {
195 const d = new Date(ms)
196 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
197}
198
199/**
200 * A numeric setting inside [min, max]. Unset (undefined) is null and silent. Anything else that is not a finite number in range
201 * (empty, text, NaN, Infinity, 1e300, zero, negative) is null too, and a warning naming the variable is pushed so the state file shows it.
202 */
203export function bounded(name: string, value: unknown, min: number, max: number, warnings: string[]): number | null {
204 if (value === undefined) return null
205 const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
206 if (Number.isFinite(n) && n >= min && n <= max) return n
207 warnings.push(`${name} ignored: not a number from ${min} to ${max}, so the default applies`)
208 return null
209}
210
211export function freshState(): State {
212 return {
213 sid: '', armed: false, armedAt: 0, windowStartAt: 0, lastPingAt: 0, lastWarmPingAt: 0, lastActivityAt: 0, expiresAt: 0, refusals: 0, guardDrops: 0,
214 lastPing: null, statusText: '', statusState: 'off', nextPingAt: 0, ctx: 0, intervalMs: 50 * MIN, windowMs: DEFAULT_WINDOW_MIN * MIN, minTokens: DEFAULT_MIN_TOKENS, maxMs: DEFAULT_MAX_HOURS * HOUR, guard: 'warn', ackedAt: 0, note: null,
215 stallMs: 20 * MIN, stallCheckMs: 5 * MIN, stalled: [],
216 quietEnabled: true, activeStart: DEFAULT_ACTIVE_START, activeEnd: DEFAULT_ACTIVE_END, skippedQuiet: 0, lastSkippedAt: 0, notBefore: 0, skippedSmall: 0, pulse: null, warnings: [],
217 }
218}
219
220/**
221 * The session id as a file name, reversible so two ids never share a file: letters, digits, `_` and `-` stay,
222 * every other character becomes %XX per UTF-8 byte (so `%` itself, `.` and `/` are encoded too).
223 * A lone UTF-16 surrogate has no UTF-8 encoding and is replaced by U+FFFD (`%EF%BF%BD`), so two ids that differ only in lone surrogates share a file.
224 */
225export function safeName(sid: string): string {
226 return Array.from(sid).map(ch => {
227 if (/[A-Za-z0-9_-]/.test(ch)) return ch
228 let enc: string
229 try { enc = encodeURIComponent(ch) } catch { enc = '%EF%BF%BD' } // a lone UTF-16 surrogate has no UTF-8 bytes: it becomes U+FFFD
230 return enc.replace(/[!'()*.~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))
231 }).join('')
232}
233
234const s = freshState()
235let timer: { cancel: () => void } | null = null
236let stallTimer: { cancel: () => void } | null = null
237/** Last event seen per subagent id (turn.step, turn.complete, tool.call carrying that agentId). Not persisted. */
238const lastEventAt = new Map<string, number>()
239let pinging = false
240/** Bumped by resetSession: a ping, tick or watchdog pass that began under an older value belongs to a session that is gone and must not write into the new one. */
241let generation = 0
242/** Bumped by resetSession and by every disarm: a stall message prepared under an older value must not be sent. */
243let epoch_ = 0
244/** Stall messages this plugin has submitted, mapped to the epoch they were prepared in. The prompt.submit hook drops one whose epoch is no longer current. */
245const stallMessages = new Map<string, number>()
246const PLUGIN_NAME = 'bun-warmer'
247let writes: Promise<void> = Promise.resolve()
248/** True while an `<id>.error.json` from a failed write sits beside the state file. */
249let errorFileWritten = false
250/** Set when neither the state file nor the error file could be written and the old state file could not be removed; shown in the status line. */
251let durabilityNote: string | null = null
252/** Tool calls started and not finished, per agent id ('' is the main conversation). A subagent with one in flight is working, not silent. */
253const inFlight = new Map<string, number>()
254/** Turn ids of main-loop turns started and not yet completed, from turn.start / turn.step / turn.complete. The API has no call that says whether the main loop is busy, so this is our own count of the events. */
255const mainTurns = new Set<string>()
256
257/** Forget everything about the session that was running, keep the configuration. Used on session.end and on a changed session id. */
258function resetSession() {
259 cancel()
260 stopStall()
261 generation++
262 epoch_++
263 stallMessages.clear()
264 pinging = false
265 const c = freshState()
266 Object.assign(s, c, { intervalMs: s.intervalMs, windowMs: s.windowMs, minTokens: s.minTokens, maxMs: s.maxMs, guard: s.guard, stallMs: s.stallMs, stallCheckMs: s.stallCheckMs, quietEnabled: s.quietEnabled, activeStart: s.activeStart, activeEnd: s.activeEnd, warnings: s.warnings })
267 lastEventAt.clear()
268 mainTurns.clear()
269 errorFileWritten = false
270 inFlight.clear()
271 durabilityNote = null
272 shownAlert = ''
273}
274
275/** /clear fires session.end but no session.start; every event handler calls this so the id is always the live one. */
276async function ensureSession($: EngineInterface) {
277 const id = await $.session.id()
278 if (s.sid === id) return
279 if (s.sid) resetSession()
280 s.sid = id
281}
282
283// The file is written beside its final name and moved over it, so a reader never sees half a file.
284// On any failure the temp file is removed and `<name>.error.json` records it; the next good write deletes that file.
285function persist($: EngineInterface) {
286 writes = writes.then(async () => {
287 if (!s.sid) return
288 try {
289 const home = await $.env.get('HOME')
290 if (!home) return
291 const dir = `${home}/.claude/bun-warmer`
292 const safe = safeName(s.sid)
293 const tmp = `${dir}/.${safe}.json.tmp`
294 const final = `${dir}/${safe}.json`
295 const errPath = `${dir}/${safe}.error.json`
296 const sid = s.sid
297 const now = await $.clock.now()
298 let failure: string | null = null
299 try {
300 await $.fs.write(tmp, JSON.stringify(stateFile(s, now), null, 2) + '\n')
301 const moved = await $.process.run(['mv', '-f', tmp, final])
302 if (moved.exitCode !== 0) failure = `mv failed: ${moved.stderr.trim() || `exit ${moved.exitCode}`}`
303 } catch (err) {
304 failure = `write failed: ${err instanceof Error ? err.message : String(err)}`
305 }
306 if (failure) {
307 $.ui.log(`bun-warmer: could not write the state file (${failure})`)
308 try { await $.process.run(['rm', '-f', tmp]) } catch { /* nothing more to do */ }
309 let errWritten = false
310 try {
311 await $.fs.write(errPath, JSON.stringify({ sessionId: sid, failedAt: isoLocal(now, true), failedAtEpoch: epoch(now), failedAtEpochMs: now, error: failure }, null, 2) + '\n')
312 errWritten = true
313 } catch (err) {
314 $.ui.log(`bun-warmer: could not write the error file either (${err instanceof Error ? err.message : String(err)})`)
315 }
316 if (errWritten) {
317 errorFileWritten = true
318 durabilityNote = null
319 } else {
320 // Nothing durable says the write failed, so an old final file would pass for current. Remove it.
321 let removed = false
322 try { removed = (await $.process.run(['rm', '-f', final])).exitCode === 0 } catch { /* handled below */ }
323 if (removed) {
324 durabilityNote = null
325 $.ui.log('bun-warmer: removed the old state file so it cannot pass for current')
326 } else {
327 durabilityNote = 'STATE FILE NOT WRITTEN and the old one could not be removed, so it is stale'
328 $.ui.log(`bun-warmer: ${durabilityNote}`)
329 }
330 status($, now)
331 }
332 } else {
333 if (durabilityNote) { durabilityNote = null; status($, now) }
334 if (errorFileWritten) {
335 // The flag clears only when the file is really gone; otherwise the next write retries the removal.
336 let gone = false
337 try { gone = (await $.process.run(['rm', '-f', errPath])).exitCode === 0 } catch { /* retried on the next write */ }
338 if (gone) errorFileWritten = false
339 else $.ui.log('bun-warmer: could not remove the old error file; will retry on the next write')
340 }
341 }
342 } catch (err) {
343 $.ui.log(`bun-warmer: could not record a state file failure (${err instanceof Error ? err.message : String(err)})`)
344 }
345 })
346 return writes
347}
348
349/** The last moment the prompt cache is known to have been refreshed: session activity or a ping that found it warm. The status line and the cold-send guard both judge warm or cold from this. */
350function lastWarmAt(st: State): number {
351 return Math.max(st.lastActivityAt, st.lastWarmPingAt)
352}
353
354/**
355 * When the scheduler may send the next ping, or 0 when it may not. Spacing counts every attempt (lastPingAt), so a failed ping
356 * is not retried sooner than one interval; warm or cold counts only lastWarmAt, so no ping, retry or not, goes out once the cache is cold.
357 */
358function nextPingAt(st: State): number {
359 const base = Math.max(st.lastActivityAt, st.lastPingAt)
360 const warmAt = lastWarmAt(st)
361 if (!base || !warmAt) return 0
362 const at = Math.max(base + st.intervalMs, st.notBefore)
363 return at < warmAt + COLD_AFTER_MS ? at : 0
364}
365
366/** What the status line would say right now, split into the routine part and the alert part (off, failed ping, stall, durability). */
367function describe(now: number): { text: string; alert: string; state: string; pingAt: number } {
368 const dur = durabilityNote ? ` · ${durabilityNote}` : ''
369 if (!s.armed) {
370 const text = s.note ? `bun-warmer off: ${s.note}${stallText()}${dur}` : (stallText() + dur).slice(3)
371 return { text, alert: text, state: 'off', pingAt: 0 }
372 }
373 const fail = s.lastPing && !s.lastPing.ok ? ` · last ping FAILED: ${s.lastPing.reason}` : ''
374 const stall = stallText()
375 // Warm or cold is the cache's state (lastWarmAt); "ping in" is nextPingAt, the same answer the scheduler acts on.
376 const base = Math.max(s.lastActivityAt, s.lastPingAt)
377 const warmAt = lastWarmAt(s)
378 const cold = !warmAt || now - warmAt >= COLD_AFTER_MS
379 const state = !warmAt ? 'idle' : cold ? 'cold' : 'warm'
380 const pingAt = nextPingAt(s)
381 const next = !base ? 'waiting for the first turn'
382 : isQuiet(now) ? `quiet hours, pings resume ${hhmm(nextActiveMoment(now, s.activeStart, s.activeEnd))}`
383 : cold ? 'cache cold, waiting for activity'
384 : s.ctx < s.minTokens ? (s.ctx ? `context under ${s.minTokens.toLocaleString('en-US')} tokens, not pinging` : 'context size unknown, not pinging')
385 : !pingAt ? `no ping before the cache goes cold in ${fmtDur(warmAt + COLD_AFTER_MS - now)}`
386 : `ping in ${fmtDur(pingAt - now)}`
387 return { text: `bun-warmer · ${state} · ${fmtDur(s.expiresAt - now)} left · ${next}${fail}${stall}${dur}`, alert: `${fail}${stall}${dur}`, state, pingAt }
388}
389
390/** The last alert shown. The UI status (and, in Remote Control, the transcript) is touched only when this changes, never for countdown ticks. */
391let shownAlert = ''
392
393/** Routine status lives in the state file (statusText) for the real status line to read. The UI is told only about alerts: off, a failed ping, a stalled subagent, a write failure. */
394function status($: EngineInterface, now: number) {
395 const d = describe(now)
396 s.statusText = d.text
397 s.statusState = d.state
398 s.nextPingAt = d.pingAt
399 // Minute counts (a stall's "23m") change on their own; only a new or cleared alert is worth a line.
400 const key = d.alert.replace(/ \d+m\b/g, '')
401 if (key === shownAlert) return
402 shownAlert = key
403 return $.ui.status(d.alert ? (s.armed ? d.text : d.alert) : undefined)
404}
405
406function stallText(): string {
407 if (!s.stalled.length) return ''
408 return ` · STALLED: ${s.stalled.map(a => `${safeId(a.id)} ${Math.trunc(a.minutesSilent)}m`).join(', ')}`
409}
410
411function cancel() {
412 if (timer) timer.cancel()
413 timer = null
414}
415
416function schedule($: EngineInterface, now: number) {
417 cancel()
418 if (!s.armed) return
419 const warmAt = lastWarmAt(s)
420 const coldAt = warmAt ? warmAt + COLD_AFTER_MS : 0
421 // A cold cache is not pinged: that would pay the full rewrite. A ping that would land at or after coldAt (a failed ping's
422 // retry, or one pushed back by quiet hours) is not scheduled; the timer wakes at coldAt so the status line turns cold, then only expiry is watched until activity returns.
423 // A ping skipped for quiet hours leaves notBefore: the next one waits for active hours or the normal interval, whichever is later.
424 const pingAt = nextPingAt(s) || (now < coldAt ? coldAt : s.expiresAt)
425 const due = Math.min(pingAt, s.expiresAt)
426 timer = $.clock.after(Math.max(1000, due - now), () => { tick($).catch(err => safeLog($, `tick failed (${errText(err)})`)) })
427}
428
429async function disarm($: EngineInterface, why: string) {
430 cancel()
431 stopStall()
432 epoch_++
433 stallMessages.clear()
434 s.armed = false
435 s.stalled = []
436 s.note = why
437 const now = await $.clock.now()
438 status($, now)
439 await persist($)
440}
441
442/**
443 * Open or restart the window. `extend` keeps the window start of the current arm: an extension (busy session at expiry)
444 * can never reach past windowStartAt + maxMs. A new arm (session start, a typed prompt, /bunwarm on) starts a new window.
445 */
446async function arm($: EngineInterface, now: number, extend = false) {
447 if (!extend || !s.armed) { s.windowStartAt = now; s.armedAt = now }
448 s.armed = true
449 s.expiresAt = Math.min(now + s.windowMs, s.windowStartAt + s.maxMs)
450 s.note = null
451 if (!stallTimer) scheduleStall($)
452 schedule($, now)
453 status($, now)
454 await persist($)
455}
456
457async function hasActiveAgents($: EngineInterface): Promise<boolean> {
458 try {
459 const list = await $.agent.list()
460 // Only `running` is work in progress; `pending` and `waiting` (e.g. a plan awaiting approval) can sit forever.
461 return list.some(a => a.status === 'running')
462 } catch {
463 return false
464 }
465}
466
467function touchAgent(id: string | undefined, now: number) {
468 if (!id) return
469 lastEventAt.set(id, now)
470}
471
472/** Starts with a notice that is harmless if it arrives late: it names the time, says when to ignore it, and suggests nothing destructive. */
473export const STALL_PREFIX = 'Bun Warmer notice sent at '
474
475/** Agent types known to the engine. Anything else is shown as `agent`: a type string comes from spawn metadata and is never trusted. */
476const KNOWN_AGENT_TYPES = ['general-purpose', 'Explore', 'Plan', 'claude', 'claude-code-guide', 'statusline-setup'] as const
477
478/** An agent id as text that cannot carry instructions: only letters, digits, `_` and `-`, at most 40 characters, anything else becomes `_`. */
479export function safeId(id: unknown): string {
480 return String(id ?? '').slice(0, 40).replace(/[^A-Za-z0-9_-]/g, '_')
481}
482
483export function safeType(type: unknown): string {
484 return KNOWN_AGENT_TYPES.find(t => t === type) ?? 'agent'
485}
486
487/** Free text kept only in the state file: printable ASCII from a short set, 80 characters at most. Never put in a model prompt or the status line. */
488export function safeDescription(text: unknown): string {
489 return String(text ?? '').slice(0, 80).replace(/[^A-Za-z0-9 _.,:;()-]/g, '?')
490}
491
492/** Fixed text plus an id, an allowlisted type, whole minutes and a time: no free text from the agent's spawn metadata. */
493function stallMessage(a: StalledAgent, sentAt: number): string {
494 const minutes = Number.isFinite(a.minutesSilent) ? Math.max(0, Math.trunc(a.minutesSilent)) : 0
495 return `${STALL_PREFIX}${hhmm(sentAt)} local time, ignore if you have since run /bunwarm off or the subagent already finished. A subagent of type ${safeType(a.type)} with id ${safeId(a.id)} had shown running but silent for ${minutes} minutes at that time. Look it up by that id in your own Agent list. Suggested, non-destructive: check whether it has produced output or is still running before deciding; do not stop or respawn it on this notice alone.`
496}
497
498/**
499 * One watchdog pass. Reads $.agent.list (local, no tokens), flags a running agent silent for stallMs once,
500 * clears a flag when the agent is no longer running or has produced an event since. Never stops anything.
501 * The one message to the orchestrator goes through $.prompt.submit (origin kind 'plugin'); the calling plugin's
502 * own prompt.submit hook is skipped by the engine, so the guard cannot drop it.
503 */
504export async function stallCheck($: EngineInterface) {
505 if (!s.armed) return
506 const token = epoch_
507 let list
508 try {
509 list = await $.agent.list()
510 } catch {
511 return
512 }
513 const now = await $.clock.now()
514 if (token !== epoch_) return
515 const running = new Map<string, { description: string; type: string }>()
516 const listedIds = new Set<string>(list.filter(a => a && typeof a.id === 'string').map(a => a.id))
517 for (const a of list) {
518 if (!a || !a.id || a.status !== 'running') continue
519 running.set(a.id, { description: a.description, type: a.type })
520 // A tool call in flight is work, however long it takes: the agent is not silent.
521 if ((inFlight.get(a.id) ?? 0) > 0) lastEventAt.set(a.id, now)
522 // An agent never seen producing an event is measured from the first time the watchdog saw it running.
523 if (!lastEventAt.has(a.id)) lastEventAt.set(a.id, now)
524 }
525 // The transcript pulse is a second signal. Asked once per check, only while some agent is running.
526 const pulse = running.size ? await readPulse($, now) : null
527 if (token !== epoch_) return
528 let changed = false
529 if (running.size) {
530 const rec: PulseRecord = { available: pulse?.available === true, reason: pulse?.available === true ? null : (pulse?.reason ?? 'not-run'), checkedAt: now, agentsSeen: pulse?.available === true ? pulse.agents.size : 0 }
531 if (!s.pulse || s.pulse.available !== rec.available || s.pulse.reason !== rec.reason || s.pulse.agentsSeen !== rec.agentsSeen) changed = true
532 s.pulse = rec
533 }
534 /** Silence in ms for an agent: the event-based figure, lowered to the pulse's figure when the pulse knows the agent. Both must exceed the threshold for a flag. */
535 const silenceOf = (id: string): number => {
536 const byEvents = now - (lastEventAt.get(id) ?? now)
537 const p = pulse?.available === true ? pulseFor(pulse.agents, id, listedIds) : undefined
538 if (!p) return byEvents // pulse unavailable or silent about this agent: the event signal alone
539 return Math.min(byEvents, p.working ? 0 : p.idleMs)
540 }
541 s.stalled = s.stalled.filter(f => {
542 const keep = running.has(f.id) && silenceOf(f.id) >= s.stallMs
543 if (!keep) changed = true
544 return keep
545 })
546 for (const [id, info] of running) {
547 const silent = silenceOf(id)
548 if (silent < s.stallMs) continue
549 const minutes = Math.round(silent / MIN)
550 const known = s.stalled.find(f => f.id === id)
551 if (known) {
552 if (known.minutesSilent !== minutes) { known.minutesSilent = minutes; changed = true }
553 continue
554 }
555 const entry: StalledAgent = { id, description: safeDescription(info.description), type: safeType(info.type), minutesSilent: minutes, firstFlaggedAt: now, messageSent: null }
556 s.stalled.push(entry)
557 changed = true
558 }
559 // $.prompt.submit queues behind a busy main loop, and the API has no way to withdraw a queued prompt. So a message goes out only
560 // while no main-loop turn is running; a flagged agent waits (messageSent null) and is sent on a later pass once the loop is idle.
561 const due = mainTurns.size === 0 ? s.stalled.filter(f => f.messageSent === null) : []
562 for (const f of due) {
563 // Disarmed, or the session changed under us while an earlier message was pending: send nothing more.
564 if (!s.armed || token !== epoch_) break
565 changed = true
566 try {
567 // Last look before the submit, with no await between this check and the call. A queued prompt cannot be withdrawn (accepted residual, README).
568 if (!s.armed || token !== epoch_ || mainTurns.size !== 0) { changed = false; break }
569 const text = stallMessage(f, now)
570 stallMessages.set(text, token)
571 const r = await Promise.race([
572 $.prompt.submit({ text }),
573 // submit resolves when the prompt enters or is queued; do not hold the watchdog on a busy loop forever.
574 new Promise<'pending'>(res => { $.clock.after(15000, () => res('pending')) }),
575 ])
576 f.messageSent = r === 'pending' ? true : !(r && 'drop' in r && r.drop)
577 } catch (err) {
578 f.messageSent = false
579 $.ui.log(`bun-warmer: could not message the orchestrator about a stalled subagent (${err instanceof Error ? err.message : String(err)})`)
580 }
581 }
582 if (changed && token === epoch_) {
583 status($, now)
584 await persist($)
585 }
586}
587
588type PulseAgent = { idleMs: number; working: boolean }
589type PulseReading = { available: true; agents: Map<string, PulseAgent | null> } | { available: false; reason: string }
590
591/**
592 * The pulse's reading for a listed agent id, matched exactly: the listed id as it is, or the pulse's `agent-<id>` file form.
593 * One direction only (never strip a prefix from the listed id), so `a1` and `agent-a1` stay two agents. The form `agent-<id>` is skipped
594 * when another listed agent carries exactly that id. Two pulse entries that could be this agent, or a duplicated id in the pulse, are ambiguous: undefined.
595 */
596function pulseFor(agents: Map<string, PulseAgent | null>, id: string, listed: ReadonlySet<string>): PulseAgent | undefined {
597 const keys = [id]
598 const prefixed = `agent-${id}`
599 if (!listed.has(prefixed)) keys.push(prefixed)
600 const hits = keys.filter(k => agents.has(k))
601 if (hits.length !== 1) return undefined
602 return agents.get(hits[0]) ?? undefined
603}
604
605/** The plugin-root candidate for session-pulse.py. A relative root would resolve against the session's project directory, which can hold anyone's bin/session-pulse.py: absolute only, trailing slashes stripped. */
606export function pluginPulseCandidate(root: unknown): { path: string | null; warning: string | null } {
607 if (typeof root === 'string' && root.startsWith('/')) return { path: `${root.replace(/\/+$/, '')}/bin/session-pulse.py`, warning: null }
608 if (root !== undefined && root !== null) return { path: null, warning: 'Plugin root ignored: it must be an absolute path (starting with /), so the pulse script is not looked up there' }
609 return { path: null, warning: null }
610}
611
612/** Where session-pulse.py is, first hit of: env BUN_WARMER_PULSE, `<plugin folder>/bin/session-pulse.py` (`$.plugin.root`). `test -f` through argv, no shell. */
613async function pulsePath($: EngineInterface): Promise<string | null> {
614 const candidates: string[] = []
615 try {
616 const env = (await $.env.get('BUN_WARMER_PULSE'))?.trim()
617 // A relative path would resolve against the session's project directory, which can hold anyone's file: absolute `.py` only.
618 if (env && env.startsWith('/') && env.endsWith('.py')) candidates.push(env)
619 else if (env) {
620 const note = 'BUN_WARMER_PULSE ignored: it must be an absolute path (starting with /) to a .py file, so the next candidate is used'
621 if (!s.warnings.includes(note)) s.warnings.push(note)
622 }
623 } catch { /* next */ }
624 try {
625 const { path, warning } = pluginPulseCandidate($.plugin.root)
626 if (path) candidates.push(path)
627 if (warning && !s.warnings.includes(warning)) s.warnings.push(warning)
628 } catch { /* next */ }
629 for (const c of candidates) {
630 try {
631 if ((await $.process.run(['test', '-f', c], { timeoutMs: 2000 })).exitCode === 0) return c
632 } catch { /* try the next */ }
633 }
634 return null
635}
636
637/**
638 * Ask session-pulse.py how long each agent's transcript has been idle. Untrusted output: only finite numbers and booleans are kept,
639 * no string from it is stored, logged or shown. Any failure is `{ available: false, reason }` with a reason word of ours.
640 */
641async function readPulse($: EngineInterface, now: number): Promise<PulseReading> {
642 void now
643 let timer: { cancel: () => void } | null = null
644 try {
645 const script = await pulsePath($)
646 if (!script) return { available: false, reason: 'script-missing' }
647 const sid = s.sid
648 if (!sid) return { available: false, reason: 'no-session-id' }
649 const timeout = new Promise<'timeout'>(res => { timer = $.clock.after(PULSE_TIMEOUT_MS, () => res('timeout')) })
650 const run = $.process.run(['python3', script, sid], { timeoutMs: PULSE_TIMEOUT_MS })
651 run.catch(() => { /* a late rejection after the race is settled */ })
652 const r = await Promise.race([run, timeout])
653 if (r === 'timeout') return { available: false, reason: 'timeout' }
654 if (r.exitCode !== 0) return { available: false, reason: 'nonzero-exit' }
655 let j: unknown
656 try { j = JSON.parse(r.stdout) } catch { return { available: false, reason: 'unparseable' } }
657 if (!j || typeof j !== 'object') return { available: false, reason: 'unparseable' }
658 const o = j as Record<string, unknown>
659 if (o.available !== true) return { available: false, reason: 'pulse-reported-unavailable' }
660 if (!Array.isArray(o.agents)) return { available: false, reason: 'unparseable' }
661 const agents = new Map<string, PulseAgent | null>()
662 for (const a of o.agents) {
663 if (!a || typeof a !== 'object') continue
664 const x = a as Record<string, unknown>
665 // The id is used only as a lookup key against ids the engine gave us; it is never shown.
666 if (typeof x.id !== 'string' || typeof x.idleSeconds !== 'number' || !Number.isFinite(x.idleSeconds) || x.idleSeconds < 0) continue
667 // A pulse id seen twice cannot be told apart: null marks it ambiguous.
668 // A reading the script flags as growing during the read, or as uncertain, is taken as written just now: never silent.
669 const recent = x.grewDuringRead === true || x.uncertain === true
670 agents.set(x.id, agents.has(x.id) ? null : { idleMs: recent ? 0 : x.idleSeconds * 1000, working: x.toolInFlight === true })
671 }
672 return { available: true, agents }
673 } catch {
674 return { available: false, reason: 'run-failed' }
675 } finally {
676 if (timer) (timer as { cancel: () => void }).cancel()
677 }
678}
679
680function errText(err: unknown): string {
681 return err instanceof Error ? err.message : String(err)
682}
683
684/** Log without ever throwing or rejecting. */
685async function safeLog($: EngineInterface, text: string) {
686 try { await $.ui.log(`bun-warmer: ${text}`) } catch { /* nowhere left to report */ }
687}
688
689function scheduleStall($: EngineInterface) {
690 if (stallTimer) stallTimer.cancel()
691 stallTimer = $.clock.after(s.stallCheckMs, () => {
692 stallCheck($)
693 .catch(err => safeLog($, `stall check failed (${errText(err)})`))
694 .finally(() => { if (stallTimer) scheduleStall($) })
695 })
696}
697
698function stopStall() {
699 if (stallTimer) stallTimer.cancel()
700 stallTimer = null
701}
702
703async function tick($: EngineInterface) {
704 timer = null
705 if (!s.armed) return
706 const gen = generation
707 const now = await $.clock.now()
708 if (gen !== generation) return
709 if (now >= s.expiresAt) {
710 // Subagents working or turns still arriving mean the session is busy, not finished.
711 const capAt = s.windowStartAt + s.maxMs
712 if (now >= capAt) return disarm($, `maximum of ${fmtDur(s.maxMs)} reached`)
713 if (now - s.lastActivityAt < s.intervalMs || await hasActiveAgents($)) return arm($, now, true)
714 return disarm($, 'window expired')
715 }
716 if (pinging) {
717 // A ping is still out (slow API): look again later rather than lose the schedule.
718 schedule($, now)
719 return
720 }
721 // Spacing counts every attempt; warm or cold counts only activity and good pings (lastWarmAt), so a failed ping never earns a retry into a cold cache.
722 const base = Math.max(s.lastActivityAt, s.lastPingAt)
723 const warmAt = lastWarmAt(s)
724 if (!base || !warmAt || now - warmAt >= COLD_AFTER_MS || now - base < s.intervalMs - 1000) {
725 schedule($, now)
726 status($, now)
727 await persist($)
728 return
729 }
730 if (s.ctx < s.minTokens) {
731 // A small or not-yet-measured context is not worth keeping warm: no ping, and no retry sooner than one interval.
732 s.skippedSmall++
733 s.notBefore = now + s.intervalMs
734 schedule($, now)
735 status($, now)
736 await persist($)
737 return
738 }
739 if (isQuiet(now)) {
740 // Due during quiet hours: skipped, not failed. Not a refusal, lastPing untouched; the cache may go cold on purpose.
741 s.skippedQuiet++
742 s.lastSkippedAt = now
743 s.notBefore = Math.max(nextActiveMoment(now, s.activeStart, s.activeEnd), now + s.intervalMs)
744 schedule($, now)
745 status($, now)
746 await persist($)
747 return
748 }
749 await ping($, now)
750}
751
752async function ping($: EngineInterface, now: number) {
753 if (pinging) return
754 pinging = true
755 const gen = generation
756 let result: PingResult
757 try {
758 let reply
759 try {
760 reply = await $.model.fork({ prompt: PING_PROMPT })
761 } catch (err) {
762 reply = null
763 result = { at: now, ok: false, reason: `ping threw: ${err instanceof Error ? err.message : String(err)}`, read: null, write: null }
764 }
765 if (reply === null) {
766 result ??= { at: now, ok: false, reason: 'the engine did not send the ping', read: null, write: null }
767 } else if (reply.isAnswered === false) {
768 const why = reply.reason === 'nothing-to-fork' ? 'no conversation to warm yet'
769 : reply.reason === 'api-error' ? `the API call failed${reply.status == null ? '' : ` (${reply.status})`}`
770 : reply.reason === 'aborted' ? 'the ping was interrupted'
771 : 'the ping returned no text'
772 result = { at: now, ok: false, reason: why, read: null, write: null }
773 } else {
774 const u = reply.usage
775 const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
776 let model: string | null = typeof (u as { model?: unknown }).model === 'string' ? (u as { model: string }).model : null
777 if (!model) { try { model = (await $.session.model()) ?? null } catch { model = null } }
778 const usd = pingUsd(model, u)
779 result = warm
780 ? { at: now, ok: true, reason: null, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, model, usd }
781 : { at: now, ok: false, reason: `the cache was already gone (read ${u.cache_read_input_tokens}, wrote ${u.cache_creation_input_tokens} tokens)`, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, model, usd }
782 }
783 } finally {
784 // resetSession already cleared the flag, and a newer ping may own it now.
785 if (gen === generation) pinging = false
786 }
787 // The session was cleared or resumed while the fork was out: its answer belongs to nobody now.
788 if (gen !== generation) return
789 s.lastPing = result
790 // Even a failed ping is an attempt that was paid for or refused; do not retry sooner than one interval.
791 s.lastPingAt = now
792 if (result.ok) s.lastWarmPingAt = now
793 s.notBefore = 0
794 if (!result.ok) s.refusals++
795 schedule($, now)
796 status($, now)
797 await persist($)
798}
799
800/** True for an id that `$.agent.list()` names: a real subagent, as opposed to an engine fork (compaction, memory, the ping) whose id no list names. */
801async function isListedAgent($: EngineInterface, id: string): Promise<boolean> {
802 try { return (await $.agent.list()).some(a => a && a.id === id) } catch { return false }
803}
804
805async function activity($: EngineInterface, human: boolean, agentId?: string) {
806 const now = await $.clock.now()
807 // The ping's own model call is not the session's activity: lastActivityAt tells a reader when work last happened.
808 // The API stamps no marker on a fork's events, so inside the ping window an event with no agentId is taken for the ping's;
809 // one carrying the id of a listed subagent is that subagent's own work and counts.
810 if (pinging && !human && !(agentId && await isListedAgent($, agentId))) return
811 s.lastActivityAt = now
812 s.notBefore = 0
813 if (human) return arm($, now)
814 if (s.armed) schedule($, now)
815 status($, now)
816 await persist($)
817}
818
819async function refreshCtx($: EngineInterface) {
820 try {
821 const live = (await $.session.usage()).context.tokens
822 if (live && live > 0) s.ctx = live
823 } catch { /* usage is advisory */ }
824}
825
826export const register: Register = (on, options) => {
827 on('session.start', async ($, e, next) => {
828 const r = await next(e)
829 resetSession()
830 s.sid = await $.session.id()
831 const warnings: string[] = []
832 s.warnings = warnings
833 const rawEnv: Record<string, string | undefined> = {
834 BUN_WARMER_INTERVAL_MIN: await $.env.get('BUN_WARMER_INTERVAL_MIN'),
835 BUN_WARMER_WINDOW_HOURS: await $.env.get('BUN_WARMER_WINDOW_HOURS'),
836 BUN_WARMER_WINDOW_MIN: await $.env.get('BUN_WARMER_WINDOW_MIN'),
837 BUN_WARMER_MIN_TOKENS: await $.env.get('BUN_WARMER_MIN_TOKENS'),
838 BUN_WARMER_MAX_HOURS: await $.env.get('BUN_WARMER_MAX_HOURS'),
839 BUN_WARMER_STALL_MIN: await $.env.get('BUN_WARMER_STALL_MIN'),
840 BUN_WARMER_STALL_CHECK_MIN: await $.env.get('BUN_WARMER_STALL_CHECK_MIN'),
841 }
842 const num = (name: string, min: number, max: number) => bounded(name, rawEnv[name], min, max, warnings)
843 const envInterval = num('BUN_WARMER_INTERVAL_MIN', ...RANGE.interval)
844 const envWindowHours = num('BUN_WARMER_WINDOW_HOURS', RANGE.window[0] / 60, RANGE.window[1] / 60)
845 const envWindowMin = num('BUN_WARMER_WINDOW_MIN', ...RANGE.window)
846 const envMinTokens = num('BUN_WARMER_MIN_TOKENS', ...RANGE.minTokens)
847 const optInterval = bounded('userConfig intervalMin', options.intervalMin, ...RANGE.interval, warnings) ?? 50
848 const optWindowMin = bounded('userConfig windowMin', options.windowMin, ...RANGE.window, warnings) ?? DEFAULT_WINDOW_MIN
849 const optMinTokens = bounded('userConfig minTokens', options.minTokens, ...RANGE.minTokens, warnings) ?? DEFAULT_MIN_TOKENS
850 const wantedInterval = envInterval ?? optInterval
851 if (wantedInterval > MAX_INTERVAL_MIN) warnings.push(`ping interval capped at ${MAX_INTERVAL_MIN} minutes: a longer interval would let the cache go cold`)
852 s.intervalMs = Math.min(wantedInterval, MAX_INTERVAL_MIN) * MIN
853 // Precedence: env WINDOW_HOURS (the old name, an override when set), then env WINDOW_MIN, then the userConfig windowMin, then 180.
854 s.windowMs = envWindowHours !== null ? envWindowHours * HOUR : (envWindowMin ?? optWindowMin) * MIN
855 s.minTokens = envMinTokens ?? optMinTokens
856 s.maxMs = (num('BUN_WARMER_MAX_HOURS', ...RANGE.maxHours) ?? DEFAULT_MAX_HOURS) * HOUR
857 s.stallMs = (num('BUN_WARMER_STALL_MIN', ...RANGE.stall) ?? 20) * MIN
858 s.stallCheckMs = (num('BUN_WARMER_STALL_CHECK_MIN', ...RANGE.stallCheck) ?? 5) * MIN
859 s.quietEnabled = !quietHoursOff(await $.env.get('BUN_WARMER_QUIET_HOURS'))
860 const rawStart = await $.env.get('BUN_WARMER_ACTIVE_START')
861 const rawEnd = await $.env.get('BUN_WARMER_ACTIVE_END')
862 s.activeStart = hourSetting(rawStart, DEFAULT_ACTIVE_START)
863 s.activeEnd = hourSetting(rawEnd, DEFAULT_ACTIVE_END)
864 if (rawStart !== undefined && hourSetting(rawStart, -1) === -1) warnings.push('BUN_WARMER_ACTIVE_START ignored: not a whole hour from 0 to 23, so the default applies')
865 if (rawEnd !== undefined && hourSetting(rawEnd, -1) === -1) warnings.push('BUN_WARMER_ACTIVE_END ignored: not a whole hour from 0 to 23, so the default applies')
866 s.guard = options.guard === 'refuse' ? 'refuse' : 'warn'
867 await $.command.register({ name: 'bunwarm', description: 'Bun Warmer: status, on, or off for the cache-warming pings', argumentHint: '[status | on | off]', immediate: true })
868 await refreshCtx($)
869 await arm($, await $.clock.now())
870 return r
871 })
872
873 on('session.end', async ($, e, next) => {
874 stopStall()
875 await disarm($, e.reason === 'clear' ? 'cleared' : 'session ended')
876 // /clear and an in-process resume go on under another id. The plugin session.start fires once per process and never for /clear
877 // (SessionStartInput has no source); the classic SessionStart hook's source 'resume' (SessionStartHookInput) is a different surface.
878 // Whether a session.start follows or not, start clean here and take the new id on the next event (ensureSession).
879 resetSession()
880 s.sid = ''
881 return next(e)
882 })
883
884 on('session.compact', async ($, e, next) => {
885 const r = await next(e)
886 if (!e.agentId && r.skip === undefined) {
887 // The API's token count is absent until the next response; never act on the pre-compaction number.
888 await ensureSession($)
889 s.ctx = 0
890 s.ackedAt = 0
891 await persist($)
892 }
893 return r
894 })
895
896 on('command.run', { command: 'bunwarm' }, async ($, e) => {
897 await ensureSession($)
898 const word = String(e.args ?? '').trim()
899 const now = await $.clock.now()
900 if (word === 'off') { await disarm($, 'turned off'); return { text: 'bun-warmer is off for this session' } }
901 if (word === 'on') { await arm($, now); return { text: `bun-warmer on for ${fmtDur(s.windowMs)}, a ping after ${fmtDur(s.intervalMs)} of quiet` } }
902 if (!s.armed) return { text: `bun-warmer is off${s.note ? ` (${s.note})` : ''}` }
903 const fail = s.lastPing && !s.lastPing.ok ? `; last ping FAILED: ${s.lastPing.reason}` : s.lastPing ? '; last ping ok' : ''
904 return { text: `bun-warmer on, ${fmtDur(s.expiresAt - now)} left, ${s.refusals} failed or refused pings${s.skippedQuiet ? `, ${s.skippedQuiet} skipped for quiet hours` : ''}${fail}` }
905 })
906
907 on('prompt.submit', async ($, e, next) => {
908 await ensureSession($)
909 const isHuman = HUMAN_ORIGINS.includes(e.origin.kind)
910 if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN_NAME && e.text.startsWith(STALL_PREFIX)) {
911 // Our own stall message: drop it if the mod was turned off or the session changed since it was prepared.
912 const prepared = stallMessages.get(e.text)
913 if (!s.armed || prepared === undefined || prepared !== epoch_) return { drop: 'bun-warmer is off or the session changed' }
914 }
915 if (!isHuman) {
916 // A subagent return, task notification, peer, schedule or plugin message is never held back.
917 await activity($, false)
918 return next(e)
919 }
920 const now = await $.clock.now()
921 // The cold period is named by the moment the cache was last refreshed, so a successful ping after the last activity keeps it warm.
922 const warmAt = lastWarmAt(s)
923 const cold = warmAt > 0 && now - warmAt >= COLD_AFTER_MS
924 const typed = typeof e.text === 'string' && !e.text.trimStart().startsWith('/')
925 if (typed && cold && s.ctx >= s.minTokens) {
926 const msg = `bun-warmer: the prompt cache went cold ${fmtDur(now - warmAt - COLD_AFTER_MS)} ago, so this send re-writes about ${s.ctx.toLocaleString('en-US')} tokens at the cache-write rate.`
927 // One drop per cold period: the resend finds ackedAt equal to the same warmAt and passes; activity or a warm ping starts a new period with a new warmAt.
928 if (s.guard === 'refuse' && s.ackedAt !== warmAt) {
929 s.ackedAt = warmAt
930 s.guardDrops++
931 await persist($)
932 return { drop: `${msg} Send it again to pay it.` }
933 }
934 if (s.guard !== 'refuse') $.ui.log(`${msg} Sending anyway.`)
935 }
936 await activity($, true)
937 return next(e)
938 })
939
940 on('turn.start', async ($, e, next) => {
941 const r = await next(e)
942 if (!e.agentId) mainTurns.add(e.turnId)
943 return r
944 })
945
946 on('turn.step', async function* ($, e, next) {
947 await ensureSession($)
948 if (!e.agentId) mainTurns.add(e.turnId) // a reload mid-turn missed the start
949 // Any loop counts, subagents included: an orchestrator waiting on them is busy, not idle.
950 touchAgent(e.agentId, await $.clock.now())
951 await activity($, false, e.agentId)
952 yield* next(e)
953 })
954
955 on('turn.complete', async ($, e, next) => {
956 if (!e.agentId) mainTurns.delete(e.turnId)
957 const r = await next(e)
958 await ensureSession($)
959 touchAgent(e.agentId, await $.clock.now())
960 await refreshCtx($)
961 await activity($, false, e.agentId)
962 return r
963 })
964
965 on('tool.call', async ($, e, next) => {
966 try { await ensureSession($) } catch { /* the id is advisory here */ }
967 const key = e.agentId ?? ''
968 try { touchAgent(e.agentId, await $.clock.now()) } catch { /* a stall clock must never block a tool call */ }
969 inFlight.set(key, (inFlight.get(key) ?? 0) + 1)
970 try {
971 return await next(e)
972 } finally {
973 const left = (inFlight.get(key) ?? 1) - 1
974 if (left > 0) inFlight.set(key, left); else inFlight.delete(key)
975 // Completion is activity too: the clock restarts when the tool ends, not only when it began.
976 try {
977 touchAgent(e.agentId, await $.clock.now())
978 await activity($, false, e.agentId)
979 } catch { /* never fail a tool call over bookkeeping */ }
980 }
981 })
982}
983