Hit The Ground Running: pushes the plan's 5-hour and weekly usage to the model, closes the agent-pool gate (no new subagents) near a limit, pauses running…

A Claude Code mod that keeps long, agent-heavy sessions inside the plan's usage limits and gets them going again after a cap resets.
session.measure) are compared with tiers. Each time a tier is crossed the model gets one short hidden note, so it knows how much room is left without being asked.SendMessage to them still works. It matches the agent-pool usage gate of the workflow this mod was written for./htgr-pause would.| Window | Warn note | Gate closes | Gate reopens | | - | - | - | - | | 5-hour | 70% | 85% | when the window resets | | Weekly | 90% | 95% | when the window resets |
Notes are edge-triggered, once per window per reset period. A jump from below the warn tier straight to the gate gives the gate note alone. When the gate closes you also get a toast; when the window resets one "gate reopened" note and toast follow.
While the gate is closed the mod answers { deny } to the Agent tool and to agent.spawn (workflow agents and teammates included). A resumed agent is not a new one and passes. The hooks fail open, as Claude Code hooks do, except that each carries a .catch that refuses only while the gate is closed, so a failed check never blocks spawning when usage is low.
The status line shows a compact reading after the mod's name: 🟢 Gate open · 5h ▰▰▰▱▱ 62% · wk ▰▰▱▱▱ 41% · ⏰ Resume 06:03 (in 2h14m). A window at its warn tier gets ⚠; a closed gate shows 🔴 Gate closed.
A resume is scheduled from three sources:
StopFailure with error: "rate_limit" the mod reads this session's usage. Only if some window reads at or above its gate tier (5-hour 85%, weekly 95%) with a reset still ahead is it treated as a plan cap; a transient 429 is ignored and nothing is scheduled./htgr-pause, typed (see below) or automatic (next section).{ sessionId, resetsAt, reason, attempts } plus the fire time), so an app restart or a mod reload re-arms it when the session starts again. A schedule more than a day overdue is dropped.The resume fires at the reset plus 30 to 90 seconds of jitter. About a minute before, a toast says so; /htgr-cancel stops it. At fire time the mod:
Safety: if you type anything in the session after the halt, the schedule is cancelled; you are back. Prompts from background tasks, the mod's own commands and isMeta rows do not count. The mod never approves anything: permission prompts still wait for you.
When a 5-hour reading reaches the autoPause tier (90% by default) while a session's main turn is running, the mod does in that session what /htgr-pause does: it stops the turn and schedules a resume at the 5-hour reset (plus jitter), with a toast. Every session with the mod runs this on its own readings, so every chat that is working when usage crosses the tier pauses; an idle chat has nothing to stop and pauses on its first response if it starts working later in the same period.
It fires once per session per 5-hour period (remembered across reloads): typing a prompt after an auto-pause cancels the resume as usual, and the session then keeps working until the next reset. autoPause takes 85, 90 (default), 95 or off.
The engine hands the mod the rate-limit figures of the last response. After a cap that reading stays frozen at the pre-halt value. The mod treats a window whose reset time has passed as reset (0%), which is what makes "the limits have reset" checkable without a fresh response. It cannot see usage spent by other sessions or devices since then; if they used the new window up, the resume simply hits the limit again and the mod, being at a new StopFailure, schedules the next reset.
Compaction runs only when both hold:
$.session.compact can run; andNever during a cap, never while gated. If compaction fails or a hook vetoes it, the resume still goes and a toast says so.
fiveHourResume optionClaude Code already has an auto-continue for 5-hour caps: the desktop app's Auto-continue when limits reset checkbox on the session-limit card, and the CLI's autoContinueAtUsageLimit setting (Continue automatically at usage limit in /config). The two are separate switches. Neither covers the weekly limit. To avoid two resumes, the mod has two modes:
| fiveHourResume | Behaviour | | - | - | | native (default) | The mod does not resume 5-hour caps. It tells you the native one is expected, and takes no action. No compaction happens in this mode: the host refuses a compaction raised from the prompt hook (it would run under the turn that hook holds), and the mod cannot tell in advance that the native resume is about to fire. | | mod | The mod owns 5-hour resumes, including compaction. Turn the native auto-continue off (desktop checkbox; autoContinueAtUsageLimit: false) or both will resume the session. |
Recommended: if you want compaction before 5-hour resumes, use fiveHourResume: mod and switch the native one off. Otherwise leave the default and the native resume, and use the mod for the gate and the notes.
weeklyResume (on by default, or off) controls weekly caps, which only this mod resumes. /htgr-pause, typed or automatic (autoPause, above), is always the mod's.
Set these in the plugin's config menu or under pluginConfigs in settings.
A timer in a sleeping machine does not fire, and the mod cannot wake it. For an unattended resume:
If the machine slept through a reset, the resume fires shortly after the session loads again (up to a day late), unless you typed first.
| Command | What it does | | - | - | | /htgr | Readings, gate state, the pending resume and its re-arm count, the options. | | /htgr-pause [HH:MM \| reset] | Stops the running turn if there is one and schedules a resume at the local time HH:MM (the next time that clock reads) or, with reset or no argument, at the next 5-hour reset. Use it to park work deliberately. | | /htgr-cancel | Cancels the pending resume. |
$.session.compact takes no agent id, so a subagent's context cannot be compacted from a mod. Subagents keep their own context, and their results flow back through the main session, which is the one this mod compacts.native mode (see above). Use mod mode.In a terminal session:
/plugin install hit-the-ground-running --marketplace Densetsu152637/Hit-The-Ground-Running
From a shell:
claude plugin install hit-the-ground-running --marketplace Densetsu152637/Hit-The-Ground-Running
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Densetsu152637/Hit-The-Ground-Running, then Add plugin.
Requires a Claude Code build with function-hook mods (2.1.308 was used to build and test).
claude plugin validate .
claude plugin test .
The hooks module is hooks/register.ts, pure helpers are in hooks/logic.ts, and the tests run the module against an in-memory engine (hooks/harness.ts).
MIT. See LICENSE.
hooks/register.ts 527 lines1import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
2
3import {
4 COMPACT_ABOVE, GATE, LABEL, MAX_REARMS, SHORT, WARN,
5 contextPercent, effective, fmtClock, fmtDuration, gating, jitter, levelOf, meter, parseWhen, pct,
6} from './logic'
7import type { Effective, Kind } from './logic'
8
9// Hit The Ground Running: pushes the plan's usage readings to the model, closes the agent-pool gate (no new
10// subagents) near a limit, and resumes the session after a cap resets, compacting first when the context is big.
11
12const TAG = '[Hit The Ground Running]'
13const RESUME_PROMPT =
14 'Usage limits reset (Hit The Ground Running). Continue where you stopped; re-check the agent-pool usage gate before spawning. If a limit is hit again, stop.'
15const COMPACT_INSTRUCTIONS = [
16 'Hit The Ground Running is compacting before an automatic resume after a usage-limit reset.',
17 'Preserve, in this order: the user\'s goals and acceptance checks; the task list or plan and the status of each item;',
18 'decisions made and why; active and queued subagent assignments with their agent IDs and what each was asked to do;',
19 'files changed and commit SHAs; and the exact next step to take when work resumes.',
20].join(' ')
21const GATE_DENY =
22 `${TAG} Agent-pool usage gate is closed: spawn no new subagents; let running ones finish, or wait for the reset. Messaging a running agent (SendMessage) is still allowed.`
23
24const MS_MIN = 60_000
25const REOPEN_SLACK_MS = 2_000
26const PERIOD_SLACK_MS = 10 * MS_MIN
27const MAX_OVERDUE_MS = 24 * 3_600_000
28const NATIVE_KEEP_MS = 6 * 3_600_000
29const DEFER_MS = 30_000
30const MAX_DEFER = 20
31const WARN_BEFORE_MS = MS_MIN
32const TICK_MS = 15_000
33const NO_READING_RETRY_MS = 15 * MS_MIN
34
35type Owner = 'mod' | 'native'
36type Reason = 'rate_limit-5h' | 'rate_limit-weekly' | 'pause' | 'native-5h'
37
38/** The pending resume: persisted in `$.store` so a reload or an app restart can arm it again. */
39type Resume = {
40 sessionId: string
41 /** When the cap resets (or the time the user paused until), epoch ms. */
42 resetsAt: number
43 /** When the resume actually fires: resetsAt plus jitter. */
44 fireAt: number
45 reason: Reason
46 attempts: number
47 haltedAt: number
48 owner: Owner
49}
50
51type Tier = { resets: number; level: number }
52
53// Machine origins of a prompt: everything else (composer, bridge, sdk, unclassified, channel) may be the person.
54const MACHINE_ORIGINS = new Set([
55 'task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'coordinator', 'observer',
56 'observer-activity', 'auto-continuation', 'plugin',
57])
58
59const isResume = (v: unknown): v is Resume => {
60 const r = v as Resume | null
61 return (
62 typeof r === 'object' && r !== null && typeof r.sessionId === 'string' && typeof r.resetsAt === 'number' &&
63 typeof r.fireAt === 'number' && typeof r.attempts === 'number' && (r.owner === 'mod' || r.owner === 'native') &&
64 typeof r.reason === 'string' && typeof r.haltedAt === 'number'
65 )
66}
67
68const freshState = () => ({
69 sid: '',
70 gated: false,
71 tiers: {} as Partial<Record<Kind, Tier>>,
72 eff: [] as Effective[],
73 resume: null as Resume | null,
74 fireTimer: null as Timer | null,
75 warnTimer: null as Timer | null,
76 tick: null as Timer | null,
77 reopenTimer: null as Timer | null,
78 deferrals: 0,
79 firing: false,
80 mainTurn: '',
81 /** The 5-hour reset whose period this session was already auto-paused in, epoch ms (0: none). */
82 pausedFor: 0,
83})
84
85let s = freshState()
86
87const cfg = { ownsFiveHour: false, resumesWeekly: true, autoPauseAt: null as number | null }
88
89const resumeKey = () => `resume:${s.sid}`
90const tiersKey = () => `tiers:${s.sid}`
91
92const toast = ($: EngineInterface, text: string, timeoutMs = 8000) => {
93 try { $.ui.toast(text, { timeoutMs }) } catch { /* a surface without toasts */ }
94}
95
96const note = async ($: EngineInterface, text: string) => {
97 try {
98 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
99 } catch { /* the engine refused the row: the gate still holds through the tool hooks */ }
100}
101
102const gateText = (eff: readonly Effective[], now: number): string =>
103 gating(eff).map(x => `${LABEL[x.kind]} usage ${pct(x.percent)}${x.resetsAt ? ` (resets ${fmtClock(x.resetsAt, now)})` : ''}`).join(' and ')
104
105// ---- status line -------------------------------------------------------------------------------------------------
106
107const setStatus = ($: EngineInterface, now: number) => {
108 try {
109 // Plain text is all a status line takes: block meters and colored dots stand in for a drawing.
110 const parts = [s.gated ? '🔴 Gate closed' : '🟢 Gate open']
111 if (s.eff.length === 0) parts.push('waiting for a usage reading')
112 else parts.push(...s.eff.map(x => `${SHORT[x.kind]} ${meter(x.percent)} ${Math.round(x.percent)}%${levelOf(x.kind, x.percent) === 1 ? ' ⚠' : ''}`))
113 const r = s.resume
114 if (r?.owner === 'mod') parts.push(`⏰ Resume ${fmtClock(r.fireAt, now)} (in ${fmtDuration(r.fireAt - now)})`)
115 else if (r?.owner === 'native') parts.push(`⏰ Native resume ~${fmtClock(r.resetsAt, now)}`)
116 $.ui.status(parts.join(' · '))
117 } catch { /* no status line on this surface */ }
118}
119
120const refreshStatus = async ($: EngineInterface) => setStatus($, await $.clock.now())
121
122// ---- usage tiers and the gate -----------------------------------------------------------------------------------
123
124const persistTiers = async ($: EngineInterface) => {
125 await $.store.set(tiersKey(), { tiers: s.tiers, gated: s.gated, pausedFor: s.pausedFor })
126}
127
128/** Compares a reading with the tiers: one note per tier crossing per reset period, one when the gate reopens. */
129const evaluate = async ($: EngineInterface, readings: readonly SessionRateLimit[]) => {
130 const now = await $.clock.now()
131 const eff = effective(readings, now)
132 s.eff = eff
133 const notes: string[] = []
134 let gateNoted = false
135 for (const x of eff) {
136 const t = s.tiers[x.kind] ?? (s.tiers[x.kind] = { resets: 0, level: 0 })
137 if (x.isExpired) t.level = 0
138 else {
139 if (t.resets && x.resetsAt !== undefined && Math.abs(x.resetsAt - t.resets) > PERIOD_SLACK_MS) t.level = 0
140 if (x.resetsAt !== undefined) t.resets = x.resetsAt
141 }
142 const level = levelOf(x.kind, x.percent)
143 if (level <= t.level) continue
144 t.level = level
145 const when = x.resetsAt ? ` (resets ${fmtClock(x.resetsAt, now)})` : ''
146 if (level === 2) {
147 gateNoted = true
148 notes.push(`${TAG} ${LABEL[x.kind]} usage ${pct(x.percent)}${when}. Agent-pool gate closed: spawn no new subagents; let running ones finish.`)
149 } else {
150 notes.push(`${TAG} ${LABEL[x.kind]} usage ${pct(x.percent)}${when}. Approaching the agent-pool gate at ${GATE[x.kind]}%: keep new subagent spawns to what the task needs.`)
151 }
152 }
153 const closing = gating(eff)
154 const isGated = closing.length > 0
155 let toastText = ''
156 if (isGated && !s.gated) {
157 s.gated = true
158 toastText = `Agent-pool gate closed: ${gateText(eff, now)}. No new subagents until it resets.`
159 if (!gateNoted) notes.push(`${TAG} ${gateText(eff, now)}. Agent-pool gate closed: spawn no new subagents; let running ones finish.`)
160 } else if (!isGated && s.gated) {
161 s.gated = false
162 const readingsText = eff.map(x => `${LABEL[x.kind]} ${pct(x.percent)}`).join(', ')
163 toastText = 'Agent-pool gate reopened: usage limits reset.'
164 notes.push(`${TAG} Usage limits reset${readingsText ? ` (${readingsText})` : ''}. Agent-pool gate reopened: subagents may be spawned again, within the workflow's usage gates.`)
165 }
166 for (const text of notes) await note($, text)
167 if (toastText) toast($, toastText)
168
169 s.reopenTimer?.cancel()
170 s.reopenTimer = null
171 if (isGated) {
172 const until = Math.max(...closing.map(x => x.resetsAt ?? now + NO_READING_RETRY_MS))
173 s.reopenTimer = $.clock.after(Math.max(until - now, 0) + REOPEN_SLACK_MS, () => { void recheck($) })
174 }
175 await persistTiers($)
176 setStatus($, now)
177 await autoPause($, eff, now)
178}
179
180const recheck = async ($: EngineInterface) => {
181 try { await evaluate($, (await $.session.usage()).rateLimits) } catch { /* the next measurement tries again */ }
182}
183
184/** The gate as the usage stands now, read fresh: the spawn hooks do not trust a cached verdict. */
185const gateNow = async ($: EngineInterface): Promise<string | null> => {
186 const now = await $.clock.now()
187 const eff = effective((await $.session.usage()).rateLimits, now)
188 const text = gateText(eff, now)
189 return text ? `${TAG} Agent-pool usage gate is closed (${text}): spawn no new subagents; let running ones finish, or wait for the reset. Messaging a running agent (SendMessage) is still allowed.` : null
190}
191
192// ---- pending resume ---------------------------------------------------------------------------------------------
193
194const stopTimers = () => {
195 s.fireTimer?.cancel(); s.fireTimer = null
196 s.warnTimer?.cancel(); s.warnTimer = null
197 s.tick?.cancel(); s.tick = null
198}
199
200const forget = async ($: EngineInterface) => {
201 stopTimers()
202 s.resume = null
203 s.deferrals = 0
204 try { await $.store.delete(resumeKey()) } catch { /* nothing stored */ }
205 await refreshStatus($)
206}
207
208const arm = async ($: EngineInterface) => {
209 stopTimers()
210 const r = s.resume
211 if (!r || r.owner !== 'mod') return
212 const now = await $.clock.now()
213 s.fireTimer = $.clock.after(Math.max(r.fireAt - now, 0), () => { void fire($) })
214 const untilWarn = r.fireAt - WARN_BEFORE_MS - now
215 s.warnTimer = $.clock.after(Math.max(untilWarn, 0), () => {
216 toast($, `Resuming in about a minute (Hit The Ground Running). /htgr-cancel to stop it.`, 10_000)
217 })
218 s.tick = $.clock.every(TICK_MS, () => { void refreshStatus($) })
219 setStatus($, now)
220}
221
222const schedule = async ($: EngineInterface, r: Omit<Resume, 'sessionId' | 'fireAt'>, spread: number) => {
223 s.sid ||= await $.session.id()
224 s.resume = { ...r, sessionId: s.sid, fireAt: r.resetsAt + spread }
225 await $.store.set(resumeKey(), s.resume)
226 await arm($)
227}
228
229const giveUp = async ($: EngineInterface, why: string) => {
230 await forget($)
231 const text = `Not resuming (Hit The Ground Running): ${why}. Continue yourself once usage allows.`
232 toast($, text, 15_000)
233 try { await $.ui.notify(text) } catch { /* no notification channel */ }
234}
235
236type CompactOutcome = { text: string; isCompacted: boolean; isFailed: boolean }
237
238/** Compacts only when the context is more than COMPACT_ABOVE full and the session is idle between turns. */
239const compactIfNeeded = async ($: EngineInterface): Promise<CompactOutcome> => {
240 const { context } = await $.session.usage()
241 const fill = contextPercent(context)
242 if (fill === undefined) return { text: 'context size unreadable, compaction skipped', isCompacted: false, isFailed: false }
243 if (fill <= COMPACT_ABOVE) return { text: `context ${Math.round(fill)}% full, no compaction needed`, isCompacted: false, isFailed: false }
244 if (s.mainTurn) return { text: 'a turn is running, compaction skipped', isCompacted: false, isFailed: false }
245 try {
246 const done = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
247 if (done.skip !== undefined) return { text: `compaction vetoed (${done.skip})`, isCompacted: false, isFailed: true }
248 const sizes = done.tokensBefore !== undefined && done.tokensAfter !== undefined ? ` (${done.tokensBefore} to ${done.tokensAfter} tokens)` : ''
249 return { text: `context compacted from ${Math.round(fill)}%${sizes}`, isCompacted: true, isFailed: false }
250 } catch (err) {
251 return { text: `compaction failed (${err instanceof Error ? err.message : String(err)})`, isCompacted: false, isFailed: true }
252 }
253}
254
255const fire = async ($: EngineInterface) => {
256 const r = s.resume
257 if (s.firing || !r || r.owner !== 'mod') return
258 s.firing = true
259 try {
260 const now = await $.clock.now()
261 if (s.mainTurn) {
262 if (s.deferrals >= MAX_DEFER) return await giveUp($, 'the session never went idle')
263 s.deferrals++
264 r.fireAt = now + DEFER_MS
265 await $.store.set(resumeKey(), r)
266 await arm($)
267 return
268 }
269 const usage = await $.session.usage()
270 const blocking = gating(effective(usage.rateLimits, now))
271 if (blocking.length > 0) {
272 if (r.attempts >= MAX_REARMS) return await giveUp($, `usage is still at the gate after ${r.attempts} re-arms`)
273 r.attempts++
274 r.resetsAt = Math.max(...blocking.map(x => x.resetsAt ?? now + NO_READING_RETRY_MS))
275 r.fireAt = r.resetsAt + jitter()
276 await $.store.set(resumeKey(), r)
277 await arm($)
278 toast($, `Usage is still high (${gateText(effective(usage.rateLimits, now), now)}); resume re-armed for ${fmtClock(r.fireAt, now)} (attempt ${r.attempts} of ${MAX_REARMS}).`, 12_000)
279 return
280 }
281 const compaction = await compactIfNeeded($)
282 await forget($)
283 toast($, compaction.isFailed ? `Resuming: ${compaction.text}; resuming anyway.` : `Resuming: ${compaction.text}.`, 10_000)
284 try {
285 await $.prompt.submit({ text: RESUME_PROMPT })
286 } catch (err) {
287 toast($, `Could not submit the resume prompt (${err instanceof Error ? err.message : String(err)}).`, 15_000)
288 }
289 } catch (err) {
290 toast($, `Resume failed (${err instanceof Error ? err.message : String(err)}). /htgr shows the state.`, 15_000)
291 } finally {
292 s.firing = false
293 }
294}
295
296/** Schedules a mod resume at `resetsAt` (plus `spread`) and stops the running main turn, if any. */
297const pauseUntil = async ($: EngineInterface, now: number, resetsAt: number, spread: number): Promise<string> => {
298 await schedule($, { resetsAt, reason: 'pause', attempts: 0, haltedAt: now, owner: 'mod' }, spread)
299 let stopped = ''
300 if (s.mainTurn) {
301 try {
302 await $.turn.abort({ turnId: s.mainTurn })
303 stopped = 'Stopped the running turn. '
304 } catch {
305 stopped = 'Could not stop the running turn (press stop yourself). '
306 }
307 }
308 const at = s.resume?.fireAt ?? resetsAt
309 return `${stopped}Resume scheduled for ${fmtClock(at, now)} (in ${fmtDuration(at - now)}). Keep the computer awake; any prompt you type cancels it, as does /htgr-cancel.`
310}
311
312/**
313 * The automatic /htgr-pause: once per 5-hour period, a session whose main turn is running when the 5-hour reading
314 * reaches the auto-pause tier is stopped and resumed after the reset. An idle session has nothing to stop; it pauses
315 * on its first response if it starts working in the same period. Typing after a pause cancels it for that period.
316 */
317const autoPause = async ($: EngineInterface, eff: readonly Effective[], now: number) => {
318 const at = cfg.autoPauseAt
319 const x = eff.find(w => w.kind === 'five_hour')
320 if (at === null || !x || x.isExpired || x.resetsAt === undefined || x.percent < at) return
321 if (!s.mainTurn || s.resume || Math.abs(s.pausedFor - x.resetsAt) <= PERIOD_SLACK_MS) return
322 s.pausedFor = x.resetsAt
323 await persistTiers($)
324 const text = await pauseUntil($, now, x.resetsAt, jitter())
325 toast($, `5-hour usage ${pct(x.percent)}: auto-paused at ${at}% (Hit The Ground Running). ${text}`, 15_000)
326}
327
328const rehydrate = async ($: EngineInterface) => {
329 const saved = await $.store.get(resumeKey())
330 if (saved === undefined) return
331 const now = await $.clock.now()
332 if (!isResume(saved) || saved.sessionId !== s.sid || now - saved.fireAt > (saved.owner === 'native' ? NATIVE_KEEP_MS : MAX_OVERDUE_MS)) {
333 await $.store.delete(resumeKey())
334 return
335 }
336 s.resume = saved
337 if (saved.owner === 'mod') {
338 // Overdue after an app restart: fire shortly, once the session has finished loading.
339 if (saved.fireAt <= now) saved.fireAt = now + 10_000
340 await arm($)
341 } else {
342 setStatus($, now)
343 }
344}
345
346export const register: Register = (on, options) => {
347 s = freshState()
348 cfg.ownsFiveHour = options.fiveHourResume === 'mod'
349 cfg.resumesWeekly = options.weeklyResume !== 'off'
350 const pauseAt = Number(options.autoPause)
351 cfg.autoPauseAt = Number.isFinite(pauseAt) && pauseAt > 0 ? pauseAt : null
352
353 // ---- hooks ------------------------------------------------------------------------------------------------------
354
355 on('session.start', async ($, e, next) => {
356 const r = await next(e)
357 try {
358 s.sid = await $.session.id()
359 await $.command.register({
360 name: 'htgr',
361 description: 'Hit The Ground Running: usage readings, agent-pool gate, pending resume',
362 immediate: true,
363 })
364 await $.command.register({
365 name: 'htgr-pause',
366 description: 'Stop the current work and resume at a time or at the next 5-hour reset (Hit The Ground Running)',
367 argumentHint: '[HH:MM | reset]',
368 immediate: true,
369 })
370 await $.command.register({
371 name: 'htgr-cancel',
372 description: 'Cancel the pending automatic resume (Hit The Ground Running)',
373 immediate: true,
374 })
375 const savedTiers = (await $.store.get(tiersKey())) as { tiers?: Partial<Record<Kind, Tier>>; gated?: boolean; pausedFor?: number } | undefined
376 if (savedTiers && typeof savedTiers === 'object') {
377 s.tiers = savedTiers.tiers ?? {}
378 s.gated = savedTiers.gated === true
379 s.pausedFor = typeof savedTiers.pausedFor === 'number' ? savedTiers.pausedFor : 0
380 }
381 await rehydrate($)
382 await recheck($)
383 } catch (err) {
384 toast($, `Hit The Ground Running could not start (${err instanceof Error ? err.message : String(err)}).`, 15_000)
385 }
386 return r
387 })
388
389 on('session.end', async ($, e, next) => {
390 try {
391 s.sid ||= e.sessionId
392 if (e.sessionId === s.sid) {
393 // A cleared conversation has nothing to resume; any other end may be resumed later, so its schedule stays stored.
394 if (e.reason === 'clear') await $.store.delete(resumeKey())
395 await $.store.delete(tiersKey())
396 }
397 stopTimers()
398 s.reopenTimer?.cancel()
399 } catch { /* ending anyway */ }
400 return next(e)
401 })
402
403 on('session.measure', async ($, e, next) => {
404 try { await evaluate($, e.rateLimits) } catch { /* the next measurement tries again */ }
405 return next(e)
406 })
407
408 on('turn.start', ($, e, next) => {
409 if (e.agentId === undefined) s.mainTurn = e.turnId
410 return next(e)
411 })
412
413 on('turn.complete', ($, e, next) => {
414 if (e.agentId === undefined) s.mainTurn = ''
415 return next(e)
416 })
417
418 // The gate: new spawns are refused while a window is at its gate tier. Running agents and SendMessage are untouched.
419 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
420 const closed = await gateNow($)
421 if (closed) return { deny: closed }
422 return next(e)
423 }).catch(($, e, next) => (!next.called && s.gated ? { deny: GATE_DENY } : next(e)))
424
425 on('agent.spawn', async ($, e, next) => {
426 // A resumed agent is the same assignment continuing, not a new one.
427 if (e.resume) return next(e)
428 const closed = await gateNow($)
429 if (closed) return { deny: closed }
430 return next(e)
431 }).catch(($, e, next) => (!next.called && s.gated && !e.resume ? { deny: GATE_DENY } : next(e)))
432
433 // Halt detection: a rate_limit stop is a plan cap only when a window reads at its gate tier with a reset still ahead
434 // (the last reading before a cap is often below 100%; a transient 429 below the gate is ignored).
435 on('classic.StopFailure', async ($, e, next) => {
436 if (e.error === 'rate_limit' && e.agent_id === undefined) {
437 try {
438 const now = await $.clock.now()
439 const usage = await $.session.usage()
440 const eff = effective(usage.rateLimits, now)
441 const capped = eff.filter(x => x.raw >= GATE[x.kind] && x.resetsAt !== undefined && x.resetsAt > now)
442 if (capped.length > 0) {
443 s.sid ||= await $.session.id()
444 const resetsAt = Math.max(...capped.map(x => x.resetsAt as number))
445 const isWeekly = capped.some(x => x.kind === 'seven_day')
446 const owner: Owner = isWeekly ? 'mod' : cfg.ownsFiveHour ? 'mod' : 'native'
447 const covered = isWeekly ? cfg.resumesWeekly : true
448 const keep = s.resume && s.resume.resetsAt >= resetsAt && (s.resume.owner === owner)
449 if (covered && !keep) {
450 const reason: Reason = isWeekly ? 'rate_limit-weekly' : cfg.ownsFiveHour ? 'rate_limit-5h' : 'native-5h'
451 const label = capped.map(x => LABEL[x.kind]).join(' and ')
452 if (owner === 'mod') {
453 await schedule($, { resetsAt, reason, attempts: 0, haltedAt: now, owner }, jitter())
454 toast($, `${label} limit reached. Auto-resume armed for ${fmtClock(s.resume?.fireAt ?? resetsAt, now)}; /htgr-cancel to stop it. Keep the computer awake until then.`, 15_000)
455 } else {
456 s.resume = { sessionId: s.sid, resetsAt, fireAt: resetsAt, reason, attempts: 0, haltedAt: now, owner }
457 await $.store.set(resumeKey(), s.resume)
458 setStatus($, now)
459 toast($, `${label} limit reached. Resuming is left to the native auto-continue (~${fmtClock(resetsAt, now)}); the mod does not compact in native mode (set fiveHourResume to mod for that).`, 15_000)
460 }
461 }
462 }
463 } catch { /* a failed check changes nothing: the session just stays halted */ }
464 }
465 return next(e)
466 })
467
468 on('prompt.submit', async ($, e, next) => {
469 try {
470 const kind = e.origin.kind
471 const isMine = e.command !== undefined && e.command.name.startsWith('htgr')
472 const isPerson = !e.isMeta && !MACHINE_ORIGINS.has(kind) && !isMine
473 if (isPerson && s.resume) {
474 const owner = s.resume.owner
475 await forget($)
476 toast($, owner === 'mod' ? 'You are back: the automatic resume was cancelled (Hit The Ground Running).' : 'You are back: pre-resume compaction was cancelled (Hit The Ground Running).')
477 } else if (kind === 'auto-continuation' && s.resume?.owner === 'native') {
478 // Claude's own auto-continue is resuming. The host refuses a compaction raised from this hook (it would run
479 // under the turn the hook holds), so the mod only drops its note here.
480 await forget($)
481 }
482 } catch { /* never stand in the way of a prompt */ }
483 return next(e)
484 })
485
486 // ---- commands ---------------------------------------------------------------------------------------------------
487
488 on('command.run', { command: 'htgr' }, async $ => {
489 const now = await $.clock.now()
490 const lines = ['Hit The Ground Running']
491 if (s.eff.length === 0) lines.push('usage no reading yet (readings come from this session\'s API responses)')
492 for (const x of s.eff) {
493 const reset = x.resetsAt ? `resets ${fmtClock(x.resetsAt, now)}${x.isExpired ? ' (passed)' : ''}` : 'reset unknown'
494 lines.push(`${LABEL[x.kind].padEnd(9)} ${pct(x.percent).padStart(6)} ${reset} (warn ${WARN[x.kind]}%, gate ${GATE[x.kind]}%)`)
495 }
496 lines.push(`gate ${s.gated ? 'CLOSED: no new subagents' : 'open'}`)
497 const r = s.resume
498 if (!r) lines.push('resume none pending')
499 else if (r.owner === 'mod') lines.push(`resume ${fmtClock(r.fireAt, now)} (in ${fmtDuration(r.fireAt - now)}), ${r.reason}, re-arms ${r.attempts}/${MAX_REARMS}; /htgr-cancel stops it`)
500 else lines.push(`resume left to the native auto-continue (~${fmtClock(r.resetsAt, now)}); no compaction in native mode`)
501 lines.push(`options fiveHourResume=${cfg.ownsFiveHour ? 'mod' : 'native'} weeklyResume=${cfg.resumesWeekly ? 'on' : 'off'} autoPause=${cfg.autoPauseAt ?? 'off'}`)
502 return { text: lines.join('\n') }
503 })
504
505 on('command.run', { command: 'htgr-cancel' }, async $ => {
506 if (!s.resume) return { text: 'Nothing is pending.' }
507 await forget($)
508 return { text: 'Cancelled the pending resume.' }
509 })
510
511 on('command.run', { command: 'htgr-pause' }, async ($, e) => {
512 const now = await $.clock.now()
513 const when = parseWhen(e.args, now)
514 if (when.kind === 'error') return { text: when.message }
515 let resetsAt: number
516 let spread = 0
517 if (when.kind === 'at') resetsAt = when.ms
518 else {
519 const five = effective((await $.session.usage()).rateLimits, now).find(x => x.kind === 'five_hour' && x.resetsAt !== undefined && x.resetsAt > now)
520 if (!five?.resetsAt) return { text: 'No upcoming 5-hour reset is known yet. Give a time: /htgr-pause HH:MM' }
521 resetsAt = five.resetsAt
522 spread = jitter()
523 }
524 return { text: await pauseUntil($, now, resetsAt, spread) }
525 })
526}
527hooks/logic.ts 108 lines1import type { SessionRateLimit } from 'claude-code'
2
3// Pure helpers of Hit The Ground Running: tiers, readings and time. No engine calls here.
4
5export type Kind = 'five_hour' | 'seven_day'
6export const KINDS: readonly Kind[] = ['five_hour', 'seven_day']
7export const LABEL: Readonly<Record<Kind, string>> = { five_hour: '5-hour', seven_day: 'weekly' }
8export const SHORT: Readonly<Record<Kind, string>> = { five_hour: '5h', seven_day: 'wk' }
9
10/** Warn tiers: the first note to the model. */
11export const WARN: Readonly<Record<Kind, number>> = { five_hour: 70, seven_day: 90 }
12/** Gate tiers: the agent-pool usage gate of the workflow closes (no new subagents). */
13export const GATE: Readonly<Record<Kind, number>> = { five_hour: 85, seven_day: 95 }
14/** Compaction runs only when the context window is more than this full. */
15export const COMPACT_ABOVE = 25
16/** Resume fires this long after the reset, spread so that many sessions do not wake together. */
17export const JITTER_MIN_MS = 30_000
18export const JITTER_MAX_MS = 90_000
19/** A gate that is still closed when a resume fires re-arms at most this often per halt. */
20export const MAX_REARMS = 2
21
22export type Effective = {
23 kind: Kind
24 /** The window's reading as the last API response gave it. */
25 raw: number
26 /** The reading after the window's reset passed: a window that rolled over reads 0. */
27 percent: number
28 /** The reset in epoch milliseconds, when the reading carried a usable one. */
29 resetsAt?: number
30 /** True when the reading is from a window whose reset has passed. */
31 isExpired: boolean
32}
33
34/**
35 * The five-hour and weekly windows of a reading, aged to `now`. The reading is the last API response's, so after a
36 * cap the figures stay frozen at the pre-halt value: a reset in the past means the window has rolled over.
37 */
38export const effective = (readings: readonly SessionRateLimit[], now: number): Effective[] => {
39 const out: Effective[] = []
40 for (const kind of KINDS) {
41 const r = readings.find(x => x.kind === kind)
42 if (!r || typeof r.percentUsed !== 'number') continue
43 const at = r.resetsAt === undefined ? NaN : Date.parse(r.resetsAt)
44 const resetsAt = Number.isFinite(at) ? at : undefined
45 const isExpired = resetsAt !== undefined && resetsAt <= now
46 out.push({ kind, raw: r.percentUsed, percent: isExpired ? 0 : r.percentUsed, resetsAt, isExpired })
47 }
48 return out
49}
50
51/** 0 below the warn tier, 1 warn, 2 gate. */
52export const levelOf = (kind: Kind, percent: number): 0 | 1 | 2 => (percent >= GATE[kind] ? 2 : percent >= WARN[kind] ? 1 : 0)
53
54/** Windows that keep the gate closed now. */
55export const gating = (eff: readonly Effective[]): Effective[] => eff.filter(x => levelOf(x.kind, x.percent) === 2)
56
57/** A five-cell text meter of a percentage, `▰▰▱▱▱`; any use shows at least one cell. */
58export const meter = (percent: number, cells = 5): string => {
59 const filled = percent <= 0 ? 0 : Math.min(cells, Math.max(1, Math.round((percent / 100) * cells)))
60 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
61}
62
63export const pct = (n: number): string => `${Math.round(n * 10) / 10}%`
64
65const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
66const two = (n: number): string => String(n).padStart(2, '0')
67
68/** Local clock time, with the weekday when it is not within a day. */
69export const fmtClock = (ms: number, now: number): string => {
70 const d = new Date(ms)
71 const hhmm = `${two(d.getHours())}:${two(d.getMinutes())}`
72 return Math.abs(ms - now) > 20 * 3_600_000 ? `${DAYS[d.getDay()]} ${hhmm}` : hhmm
73}
74
75export const fmtDuration = (ms: number): string => {
76 const s = Math.max(0, Math.round(ms / 1000))
77 if (s < 60) return `${s}s`
78 const m = Math.floor(s / 60)
79 if (m < 60) return `${m}m${s % 60 ? two(s % 60) + 's' : ''}`
80 const h = Math.floor(m / 60)
81 return `${h}h${two(m % 60)}m`
82}
83
84export type When = { kind: 'reset' } | { kind: 'at'; ms: number } | { kind: 'error'; message: string }
85
86/** `reset` (or nothing) or a local `HH:MM`, the next time that clock reads. */
87export const parseWhen = (arg: string, now: number): When => {
88 const text = arg.trim().toLowerCase()
89 if (text === '' || text === 'reset') return { kind: 'reset' }
90 const m = /^(\d{1,2}):(\d{2})$/.exec(text)
91 if (!m) return { kind: 'error', message: `"${arg.trim()}" is not a time: use HH:MM (24-hour, local) or reset` }
92 const hours = Number(m[1])
93 const minutes = Number(m[2])
94 if (hours > 23 || minutes > 59) return { kind: 'error', message: `"${arg.trim()}" is not a clock time` }
95 const d = new Date(now)
96 d.setHours(hours, minutes, 0, 0)
97 if (d.getTime() <= now + 60_000) d.setDate(d.getDate() + 1)
98 return { kind: 'at', ms: d.getTime() }
99}
100
101export const jitter = (): number => JITTER_MIN_MS + Math.floor(Math.random() * (JITTER_MAX_MS - JITTER_MIN_MS + 1))
102
103/** Context fill in percent: from the token count when there is one (a whole percentage can hide 25.4), else the reported figure. */
104export const contextPercent = (ctx: { tokens?: number; window: number; percent?: number }): number | undefined => {
105 if (ctx.tokens !== undefined && ctx.window > 0) return (ctx.tokens / ctx.window) * 100
106 return ctx.percent
107}
108