SLOPSHOPPER

hit-the-ground-running

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…

newguardcommandtoaststatusprompt
v0.2.0MITupdated 2026-10-26Densetsu152637/Hit-The-Ground-Running
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hit-the-ground-running
› 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 › /htgr ⎿ hit-the-ground-running: Hit The Ground Running ⎿ hit-the-ground-running: 5-hour 31% reset unknown (warn 70%, gate 85%) ⎿ hit-the-ground-running: gate open ⎿ hit-the-ground-running: resume none pending ⎿ hit-the-ground-running: options fiveHourResume=native weeklyResume=on autoPause=90 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ hit-the-ground-running: 🟢 Gate open · 5h ▰▰▱▱▱ 31%
README

Hit The Ground 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.

  • Usage push. The plan's 5-hour and weekly readings (pushed by the engine as 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.
  • Spawn gate. Near a limit the mod refuses new subagents. Running agents finish and SendMessage to them still works. It matches the agent-pool usage gate of the workflow this mod was written for.
  • Auto-pause. At 90% of the 5-hour limit, every session with a running turn stops and schedules its resume for the 5-hour reset, as /htgr-pause would.
  • Auto-resume. When a rate-limit stop is a real plan cap, the mod waits for the reset and then sends a clearly labeled resume prompt.
  • Compaction before resume. If more than 25% of the context window is in use when the limits have reset, the session is compacted first, with instructions that keep the plan, decisions, subagent assignments, commits and the exact next step.

Tiers and the gate

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

Auto-resume

A resume is scheduled from three sources:

  1. A rate-limit cap. On a 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.
  2. /htgr-pause, typed (see below) or automatic (next section).
  3. A stored schedule. The schedule is kept in the mod's store ({ 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:

  1. waits for the session to be idle (it retries every 30 seconds for up to 10 minutes);
  2. re-reads the usage. If a window is still at its gate tier it re-arms for that window's reset. That counts as an attempt; after 2 re-arms it stops and tells you (toast and notification);
  3. compacts if the context is over 25% (below);
  4. submits: "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." The model reads it as a message from this 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.

Auto-pause at 90% of the 5-hour limit

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 reading is this session's last API response

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 before resume

Compaction runs only when both hold:

  1. the limits have reset (re-checked readings are below the gate tiers) and the session is idle between turns, so $.session.compact can run; and
  2. more than 25% of the context window is used, from the last response's token count over the window. If the size cannot be read, compaction is skipped.

Never during a cap, never while gated. If compaction fails or a hook vetoes it, the resume still goes and a toast says so.

Native auto-continue and the fiveHourResume option

Claude 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.

Keep the computer awake

A timer in a sleeping machine does not fire, and the mod cannot wake it. For an unattended resume:

  • Desktop app: Settings > This computer > System > Keep computer awake. The app must stay open; a closed laptop lid still sleeps the machine.
  • CLI: the terminal session must be open and the machine awake.

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.

Commands

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

Not feasible

  • Compacting subagents. $.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.
  • Compaction during a cap. The summary is a model request; it needs usage. The mod only compacts after the reset.
  • Compaction in native mode (see above). Use mod mode.
  • Waking a sleeping machine. See above.
  • Account-wide fresh usage. The mod has this session's last API response, not a live account query.

Install

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).

Develop

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).

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 527 lines
1import 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}
527
hooks/logic.ts 108 lines
1import 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