SLOPSHOPPER

bun-warmer

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…

guardcommandstatuspromptmodelprocess
v0.1.0no licenseupdated 2026-10-17eugeniawang/bun-warmer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bun-warmer
› 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 › /bunwarm ⎿ bun-warmer: bun-warmer on, 3h00m left, 0 failed or refused pings ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Bun Warmer

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.

Requirements

  • Claude Code with the function-hook mod (plugin) API, which is early access. The plugin does not declare a minimum version; it has been tested on Claude Code 2.1.302.
  • A POSIX host (macOS or Linux). Bun Warmer builds paths with / 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.

Install

  1. Clone the repo to a folder of your choice:
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.

  1. Try it for one session:
claude --plugin-dir /path/to/bun-warmer
  1. To load it in every session, add its folder to 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" } }
  1. Start a new Claude Code session. A running session does not pick up a settings change.
  1. Type /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.

If you use cache-tax

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.

Usage

Bun Warmer arms itself when a session starts and when you type a prompt. You do not need to do anything else.

Slash command:

CommandWhat it does
/bunwarmShows status: time left in the window, failed or refused pings, pings skipped for quiet hours
/bunwarm offStops Bun Warmer for this session
/bunwarm onStarts 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>.

Configuration

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.

SettingDefaultHow to set it
Ping interval50 minutes, capped at 55env BUN_WARMER_INTERVAL_MIN, or config intervalMin
Keep-warm window180 minutes (3 hours)env BUN_WARMER_WINDOW_HOURS (hours), else env BUN_WARMER_WINDOW_MIN, else config windowMin
Hard cap on window extensions12 hoursenv BUN_WARMER_MAX_HOURS
Minimum context to ping or warn50,000 tokensenv BUN_WARMER_MIN_TOKENS, or config minTokens
Cold-send guard (what happens when you type into a cold cache)warnconfig guard: warn or refuse
First active hour6env BUN_WARMER_ACTIVE_START (0-23)
First quiet hour23env BUN_WARMER_ACTIVE_END (0-23, exclusive)
Quiet hoursonenv BUN_WARMER_QUIET_HOURS=off pings around the clock, still inside the window
Stall threshold20 minutesenv BUN_WARMER_STALL_MIN
Stall check interval5 minutesenv BUN_WARMER_STALL_CHECK_MIN
Pulse script path<plugin folder>/bin/session-pulse.pyenv 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.

How it works

Pings

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:

  • a cache that is already cold (60 minutes since any activity), because the ping would pay the full re-write;
  • a session whose context is under the minimum token count, or whose size is not yet known (the size comes from $.session.usage() after each response);
  • during quiet hours (06:00 to 23:00 local time is active by default).

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.

Subagents and 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.

Cold-send guard

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

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.
  • An in-process /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.
  • Starting a new process with 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.

Stall watchdog

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.

State file

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:

  • Use lastActivityAt for real activity. Pings move lastPingAt only.
  • Time fields are 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.
  • Check freshness with 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.

Limitations

  • The mod API cannot withdraw a queued prompt. Bun Warmer re-checks its state just before it sends a stall message, but if you run /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.
  • If a turn's completion event is never delivered, Bun Warmer keeps treating the main conversation as busy and holds stall messages until the session resets.
  • A ping's events carry no marker. While a ping is in flight, a main-conversation event with no agent id is taken for the ping's and ignored. The next event after the ping counts.
  • A subagent that streams for a long time inside one model step, with no tool call, can look quiet to the watchdog.
  • The transcript layout the pulse reads is not a public Claude Code contract and may change. Idle times come from the wall clock, so they jump after the machine sleeps.
  • The transcript pulse finds a subagent by matching the id $.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.
  • If $.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.
  • Quiet hours follow the time zone of the process running the mod. A host that runs mods in a different zone would shift the hours.
  • There is no API call to close a session, so Bun Warmer never closes one.
  • Workflow agents do not appear in $.agent.list() or in the transcript layout, so the watchdog cannot see them.

Testing

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

License: not yet chosen.

Source 1 files
hooks/register.ts 983 lines
1import 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