SLOPSHOPPER

Keep going

Keeps unattended sessions going: continues after usage limits reset, retries API overload with backoff, resumes cut-off responses, and compacts idle sessions…

newbandspinnerguardcommandstatus
v0.1.0MITupdated 2026-10-06someonewithpc/claude-mod-keep-going
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keep-going
› fix the failing auth test and add an audit log call ● keep-going: watching session preview- (interactive) ⏺ 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 › /keep-going ⎿ keep-going: nothing pending ⎿ keep-going: ⎿ keep-going: /keep-going [status] what is pending in this session ⎿ keep-going: /keep-going continue send the pending continue now ⎿ keep-going: /keep-going cancel drop the pending continue or compaction ⎿ keep-going: /keep-going compact [what] compact once idle, keeping what you name ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

keep-going

A Claude Code mod that keeps unattended sessions going through usage limits, API errors and full contexts.

Leave Claude Code working overnight and something usually stops it: the session limit, an overloaded API, a response cut off by a dropped connection. keep-going does what you would do if you were there. It waits for the limit to reset and sends "continue", retries API errors with backoff, resumes cut-off responses, and compacts an idle session before its prompt cache expires.

It replaces claude-keep-going, which did the same from outside Claude Code by watching a tmux pane. As a mod it runs inside Claude Code: it reads the turn's own result instead of the screen, and sends prompts and compactions through the mod API instead of typing keys. It needs no tmux, no settings hooks and no background process.

Install

Needs Claude Code 2.1.287 or later. At the Claude Code prompt:

/plugin install keep-going --marketplace someonewithpc/claude-mod-keep-going

Answer y to add the marketplace, then pick a scope. It is active right away.

To load a checkout for one session instead: claude --plugin-dir ./claude-mod-keep-going.

With Nix, the flake has NixOS and home-manager modules:

inputs.claude-mod-keep-going.url = "github:someonewithpc/claude-mod-keep-going";
# with claude-mod-keep-going.nixosModules.default (or .homeManagerModules.default) imported:
programs.claude-mod-keep-going = {
  enable = true;
  settings.compact = { enabled = true; trigger = "both"; };
};

Both modules put the mod's store path in CLAUDE_CODE_PLUGIN_DIRS and write settings to the config file below. The home-manager module also merges that path into the env block of ~/.claude/settings.json, which every Claude Code session reads however it was started; a session variable only reaches shells started after the next login. Turn that off with pluginDirsInSettings = false.

What it does

Usage limits. When a turn ends on "You've hit your session limit · resets 3:50pm (Europe/Lisbon)", it waits until the reset plus marginSeconds and sends retryMessage. The reset time comes from the rate-limit windows Claude Code reports. Failing that, it reads the time off the message (time zones and DST included), and failing that it waits fallbackWaitHours.

Claude Code can continue on its own after a limit, but not always: it won't when the reset is more than a day out (most weekly limits), after repeated hits, or when it was turned off or cancelled. keep-going reads its notices ("continuing automatically at 9:42am", "will not resume on its own"). With native.usageLimit: "defer" (the default), it gives Claude Code native.graceSeconds past the reset while it says it will continue, and sends at the reset when it says it won't. Before sending, it checks that the API answers, for up to networkCheck.maxWaitMinutes, so a laptop that just woke up doesn't burn a retry. It stops after maxRetries continues that hit the limit again.

When Claude Code can't continue on its own it opens the /rate-limit-options menu, whose first option can be a paid one. keep-going answers it the way "Stop and wait for limit to reset" would, and waits. A /rate-limit-options you type yourself still opens it; native.rateLimitMenu: "show" lets every one through.

One-model limits (opt-in). On "You've hit your Opus limit", it sends that model's requests to the one modelFallback.map names until the limit resets, then lets them go back. It rewrites each request, for subagents too, and leaves /model and your saved default alone. An alias like sonnet resolves to the id that family last answered with; if the fallback model doesn't answer at all, it waits for the original limit instead.

API errors. A 529, a 5xx or the API-level 429 ("Server is temporarily limiting requests") gets retried on an exponential schedule with full jitter, up to maxRetries and overload.maxTotalWaitMinutes in all. Claude Code has already retried the request itself by then, so this covers outages that last minutes, not seconds.

Cut-off responses. "Connection lost mid-response", "Your computer went to sleep mid-response" and the other truncated-stream errors get one continue after streamInterrupted.retryDelaySeconds, up to streamInterrupted.maxRetries.

Safeguard false positives. A turn the safeguards flagged gets re-sent a few times (safeguard.maxRetries).

Near-limit wrap-up. At about 95% of the 5-hour window Claude Code tells the model to wrap up, and the model stops with a list of what's left. keep-going sends one continue after that so work runs on to the real limit.

Idle compaction (opt-in). A compaction re-reads the whole context once. While the prompt cache is warm that read is cheap; after it expires the next message re-sends everything at full price anyway. So keep-going compacts an idle session compact.settle.marginSeconds before the cache expires, when:

  • Claude asked for it with the request_compact tool, or you did with /keep-going compact, and compact.trigger is request or both
  • the context is over compact.minContextPercent and compact.minContextTokens, and compact.trigger is policy or both
  • Claude's last message mentions /compact, with compact.matchLastMessage

It waits for running subagents, scheduled wakeups (/loop, ScheduleWakeup), crons and workflows first unless compact.waitForAgents is false, and can be limited to a time window (compact.window) or to sessions nobody has typed in for compact.awayMinutes. Compacting never touches what you have typed in the prompt box.

No mod event carries the prompt cache's expiry, so keep-going estimates it from the end of the last turn and the cache TTL (compact.cacheTtlMinutes, or 60 minutes on a subscription and 5 on an API key), and treats a model switch since then as a cold cache. For the exact time, have your statusline command save its input, which carries prompt_cache.expires_at:

input=$(cat)
sid=$(jq -r .session_id <<<"$input")
dir="${XDG_RUNTIME_DIR:-/tmp}/claude-keep-going/statusline"
mkdir -p "$dir" && printf '%s' "$input" > "$dir/$sid.json"

Print runs. claude -p ends with its one result, so nothing could continue it afterwards. In a print run keep-going sends a request that failed before any of its answer arrived again, inside the run: after the reset for a usage limit, on the overload schedule otherwise, for up to print.maxWaitHours.

Seeing what it does

While something is pending, a line above the prompt says what and when, with buttons to act now or cancel:

keep-going: usage limit, continuing at 15:51 (in 1h20m)  Continue now  Cancel

For a custom statusline, each session's badge is in $XDG_RUNTIME_DIR/claude-keep-going/badge/<session_id>: 🟢KG while watching, ⏳KG 1h20m during a usage wait, 🟠KG 45s before an API-error retry, 🗜12m added when a compaction is scheduled, 🔴KG once it gave up. The statusline command gets session_id in its JSON input:

sid=$(jq -r .session_id <<<"$input")
badge=$(cat "${XDG_RUNTIME_DIR:-/tmp}/claude-keep-going/badge/$sid" 2>/dev/null)

What it does shows up in the transcript as dim lines, and every decision goes to $XDG_STATE_HOME/claude-keep-going/logs/mod-<date>-<session>.log. With debug.dumpEvents, the payloads of the events it acts on go next to it, in .events.jsonl.

/keep-going takes these:

Command
/keep-goingwhat is pending
/keep-going continuesend the pending continue now
/keep-going canceldrop the pending continue or compaction
/keep-going compact [focus]compact once idle, keeping focus
/keep-going pause, resumestop or restart acting in this session
/keep-going logthe last 20 log lines

A wait survives a restart: claude --continue picks it up again.

Configuration

keep-going reads /etc/xdg/claude-keep-going/config.json (or each $XDG_CONFIG_DIRS entry), then ~/.config/claude-keep-going/config.json on top, merged one level deep. These are the paths claude-keep-going used, so an existing config keeps working; keys that only made sense for screen scraping are ignored. Three switches are also rows in /config: idle compaction, model fallback and the band above the prompt.

{
  "maxRetries": 5,
  "marginSeconds": 60,
  "fallbackWaitHours": 5,
  "retryMessage": "Continue where you left off. The previous attempt was rate limited.",
  "customPatterns": [],            // extra regexes that mark an error as a usage limit
  "native": { "usageLimit": "defer", "graceSeconds": 180, "rateLimitMenu": "skip" },
  "print": { "enabled": true, "maxWaitHours": 6 },
  "debug": { "dumpEvents": false },
  "networkCheck": { "enabled": true, "url": "https://api.anthropic.com/", "maxWaitMinutes": 10 },
  "overload": {
    "enabled": true,
    "backoffSeconds": [30, 60, 120, 240, 300],
    "steadyStateSeconds": 300,
    "jitterMode": "full",          // or "proportional", with jitterPct
    "jitterPct": 15,
    "maxTotalWaitMinutes": 120,
    "retryMessage": "Continue where you left off."
  },
  "safeguard": { "enabled": true, "maxRetries": 3, "retryDelaySeconds": 8, "retryMessage": "continue" },
  "streamInterrupted": { "enabled": true, "maxRetries": 2, "retryDelaySeconds": 5, "retryMessage": "continue" },
  "nearLimitWrapUp": { "enabled": true, "maxRetries": 3, "retryMessage": "continue" },
  "modelFallback": { "enabled": false, "map": { "Opus": "sonnet" } },
  "compact": {
    "enabled": false,
    "trigger": "request",          // "request", "policy" or "both"
    "minContextPercent": 40,
    "minContextTokens": null,
    "settle": { "mode": "before-expiry", "marginSeconds": 300, "minutes": 4 },
    "cacheTtlMinutes": null,       // null: 60 on a subscription, 5 on an API key
    "focus": "",
    "matchLastMessage": false,
    "minIntervalMinutes": 30,
    "waitForAgents": true,
    "window": null,                // { "start": "22:00", "end": "06:00" }
    "awayMinutes": null
  },
  "ui": { "statusLine": "active", "band": true }
}

What it doesn't do

  • Keep the process alive. A mod lives inside Claude Code, so it stops when Claude Code exits. To survive a dropped SSH session or a closed terminal, run Claude Code in tmux or screen.
  • Sessions where mods are off: --safe-mode, disableAllHooks, or an organization that allows managed mods only.

Development

claude plugin validate .
claude plugin test .                    # the mod, against the engine
node --test tests/node/*.test.mjs       # the reset-time parser, across time zones

hooks/register.tsx wires Claude Code's events to hooks/keeper.ts, which holds the state and decides what to do. Its engine calls go through an io object of closures, so tests/keeper.test.ts drives it on a fake clock.

License

MIT

Source 9 files
hooks/register.tsx 415 lines
1import { read } from 'claude-code'
2import type { Register, TurnStepChunk } from 'claude-code'
3
4import { overloadWaitMs } from './backoff'
5import { applyOptions, mergeFiles, validate } from './config'
6import { backgroundOf, isPersonPrompt, Keeper } from './keeper'
7
8const TOOL = 'request_compact'
9const TOOL_NAME = 'mcp__keep-going__request_compact'
10const MAX_LOG_LINES = 2000
11
12const WRAP_UP = /Approaching your [\w-]+(?: usage)? limit|\[Usage limit approaching\. Checkpoint now/
13
14const HELP = [
15  '/keep-going [status]       what is pending in this session',
16  '/keep-going continue       send the pending continue now',
17  '/keep-going cancel         drop the pending continue or compaction',
18  '/keep-going compact [what] compact once idle, keeping what you name',
19  '/keep-going pause|resume   stop or restart acting in this session',
20].join('\n')
21
22function parsed(text: string): unknown {
23  try {
24    return JSON.parse(text)
25  } catch {
26    return null
27  }
28}
29
30function textOf(content: unknown): string {
31  if (typeof content === 'string') return content
32  if (!Array.isArray(content)) return ''
33  return content
34    .map((block: { type?: unknown; text?: unknown }) => (block?.type === 'text' && typeof block.text === 'string' ? block.text : ''))
35    .join('\n')
36}
37
38export const register: Register = (on, options) => {
39  const lines: string[] = []
40  let logPath = ''
41  let isLogDirty = false
42  let uiLog: ((text: string, toTranscript: boolean) => void) | null = null
43  let flushLog: () => Promise<void> = async () => {}
44  let badgePath = ''
45  let statuslinePath = ''
46  let isPrintRun = false
47  const events: string[] = []
48
49  const dump = (event: string, e: unknown) => {
50    if (!keeper.config.debug.dumpEvents) return
51    events.push(JSON.stringify({ at: new Date().toISOString(), event, e }).slice(0, 8000))
52    isLogDirty = true
53  }
54
55  const write = (level: string, line: string, toTranscript: boolean) => {
56    const d = new Date()
57    const stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} `
58      + `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
59    lines.push(`[${stamp}] [${level}] ${line}`)
60    if (lines.length > MAX_LOG_LINES) lines.splice(0, lines.length - MAX_LOG_LINES)
61    isLogDirty = true
62    uiLog?.(line, toTranscript)
63  }
64
65  const keeper = new Keeper({
66    info: (line) => write('INFO', line, false),
67    warn: (line) => write('WARN', line, false),
68    notice: (line) => write('INFO', line, true),
69  })
70
71  on('session.start', async ($, e, next) => {
72    const CORE = { plugin: 'keep-going', key: 'core' } as const
73    const VIEW = { plugin: 'keep-going', key: 'view' } as const
74    uiLog = (text, toTranscript) => $.ui.log(text, { to: toTranscript ? 'transcript' : 'debug' })
75    flushLog = async () => {
76      if (!isLogDirty || logPath === '') return
77      isLogDirty = false
78      try {
79        await $.fs.write(logPath, `${lines.join('\n')}\n`)
80        if (events.length > 0) await $.fs.write(logPath.replace(/\.log$/, '.events.jsonl'), `${events.join('\n')}\n`)
81      } catch {
82        isLogDirty = true
83      }
84    }
85    keeper.io = {
86      now: () => $.clock.now(),
87      saveState: async (core, view) => {
88        await $.state.set(CORE, core)
89        if (view !== null) await $.state.set(VIEW, view)
90      },
91      loadCore: async () => (await $.state.get(CORE)).value,
92      status: (text) => $.ui.status(text),
93      writeBadge: (badge) => $.fs.write(badgePath, badge === '' ? '' : `${badge}\n`),
94      storeGet: (key) => $.store.get(key),
95      storeSet: (key, value) => $.store.set(key, value),
96      storeKeys: () => $.store.keys(),
97      storeDelete: (key) => $.store.delete(key),
98      rateLimits: async () => (await $.session.usage()).rateLimits,
99      context: async () => {
100        const usage = await $.session.usage()
101        return {
102          percent: usage.context.percent ?? null,
103          tokens: usage.context.tokens ?? null,
104          isSubscription: usage.rateLimits.length > 0,
105        }
106      },
107      model: () => $.session.model(),
108      lastAssistantText: async () => {
109        const last = (await $.session.messages()).at(-1)
110        return last?.role === 'assistant' ? last.text : ''
111      },
112      cacheExpiresAt: async () => {
113        const saved = parsed(await $.fs.read(statuslinePath).catch(() => '')) as
114          | { prompt_cache?: { expires_at?: unknown; warm?: unknown } }
115          | null
116        const cache = saved?.prompt_cache
117        if (cache === undefined) return null
118        return {
119          expiresAt: typeof cache.expires_at === 'number' ? cache.expires_at * 1000 : null,
120          isWarm: cache.warm !== false,
121        }
122      },
123      busyAgents: async () => (await $.agent.list())
124        .filter((agent) => agent.status === 'pending' || agent.status === 'running').length,
125      submit: async (text) => {
126        await $.prompt.submit({ text, asUser: true })
127      },
128      compact: async (focus) => {
129        const result = await $.session.compact(focus ? { instructions: focus } : {})
130        return result && 'skip' in result && typeof result.skip === 'string' ? result.skip : null
131      },
132      isReachable: (url, timeoutMs) => Promise.race([
133        $.http.fetch(url, { method: 'HEAD' }).then(() => true, () => false),
134        $.clock.sleep(timeoutMs).then(() => false, () => false),
135      ]),
136      after: (ms, fn) => {
137        $.clock.after(ms, fn)
138      },
139    }
140    const home = (await $.env.get('HOME')) ?? ''
141    const configHome = (await $.env.get('XDG_CONFIG_HOME')) || `${home}/.config`
142    const configDirs = ((await $.env.get('XDG_CONFIG_DIRS')) || '/etc/xdg').split(':').filter(Boolean)
143    const stateHome = (await $.env.get('XDG_STATE_HOME')) || `${home}/.local/state`
144    const runtimeDir = (await $.env.get('XDG_RUNTIME_DIR')) || '/tmp'
145    isPrintRun = !e.isInteractive && e.surface === null
146    dump('session.start', e)
147
148    const files = []
149    for (const path of [...[...configDirs].reverse().map((dir) => `${dir}/claude-keep-going/config.json`), `${configHome}/claude-keep-going/config.json`]) {
150      files.push(parsed(await $.fs.read(path).catch(() => '')))
151    }
152    keeper.config = validate(applyOptions(mergeFiles(files), options))
153
154    const sessionId = await $.session.id()
155    const day = new Date().toISOString().slice(0, 10)
156    logPath = `${stateHome}/claude-keep-going/logs/mod-${day}-${sessionId.slice(0, 8)}.log`
157    badgePath = `${runtimeDir}/claude-keep-going/badge/${sessionId}`
158    statuslinePath = `${runtimeDir}/claude-keep-going/statusline/${sessionId}.json`
159    await keeper.restore(sessionId)
160
161    try {
162      await $.command.register({
163        name: 'keep-going',
164        description: 'Show or act on what keep-going has pending: continue, cancel, compact, pause',
165        argumentHint: '[status|continue|cancel|compact [focus]|pause|resume]',
166        immediate: true,
167      })
168    } catch (error) {
169      write('WARN', `could not register /keep-going: ${String(error)}`, false)
170    }
171
172    const c = keeper.config.compact
173    if (c.enabled && c.trigger !== 'policy') {
174      try {
175        await $.tool.register({
176          name: TOOL,
177          description:
178            'Ask for this conversation to be compacted once it has been idle a while, before the prompt cache '
179            + 'expires. Call it when you finish a large piece of work and what comes next does not need the '
180            + 'details of it. Nothing happens while work is still running. `focus` names what the summary should keep.',
181          inputSchema: {
182            type: 'object',
183            properties: { focus: { type: 'string', description: 'What the summary should keep or stress' } },
184          },
185        })
186      } catch (error) {
187        write('WARN', `could not register the ${TOOL} tool: ${String(error)}`, false)
188      }
189    }
190
191    $.clock.every(keeper.config.tickSeconds * 1000, () => {
192      void keeper.tick().then(() => flushLog())
193    })
194    void keeper.sweepStore().catch(() => {})
195    write('INFO', `watching session ${sessionId.slice(0, 8)} (${e.isInteractive ? 'interactive' : 'headless'})`, false)
196    await keeper.save()
197    return next(e)
198  })
199
200  on('session.end', async ($, e, next) => {
201    await flushLog()
202    if (badgePath !== '') await $.fs.write(badgePath, '').catch(() => {})
203    return next(e)
204  })
205
206  on('turn.start', async ($, e, next) => {
207    await keeper.onTurnStart()
208    return next(e)
209  })
210
211  on('turn.complete', async ($, e, next) => {
212    if (e.agentId === undefined) await keeper.onTurnComplete(e.reason, e.answer)
213    return next(e)
214  })
215
216  on('classic.StopFailure', async ($, e, next) => {
217    await keeper.onStopFailure(e.error, e.last_assistant_message ?? e.error_details ?? '')
218    return next(e)
219  })
220
221  on('classic.Stop', async ($, e, next) => {
222    keeper.onStop(backgroundOf(e.background_tasks), e.session_crons?.length ?? 0)
223    return next(e)
224  })
225
226  on('classic.Notification', async ($, e, next) => {
227    keeper.onNotification(await $.clock.now(), e.notification_type)
228    await keeper.save()
229    return next(e)
230  })
231
232  on('classic.PostModelSwitch', async ($, e, next) => {
233    keeper.core.cacheTtlMs = e.cache_ttl === '1h' ? 3600_000 : 300_000
234    return next(e)
235  })
236
237  on('turn.step', async function* ($, e, next) {
238    const model = keeper.stepModel(e.model, Date.now())
239    const sent = model === null ? e : { ...e, model }
240    const isRetried = isPrintRun && keeper.config.print.enabled && e.agentId === undefined
241    const deadline = Date.now() + keeper.config.print.maxWaitHours * 3600_000
242
243    // A print run ends with its one result, so nothing could continue it
244    // afterwards: a request that failed before any of its answer arrived is
245    // sent again here, after the wait a session would have done.
246    for (let attempt = 0; ; attempt++) {
247      const stream = next(sent)
248      let chunks = 0
249      const held: TurnStepChunk[] = []
250      let step = await stream.next()
251      while (step.done !== true) {
252        const chunk = step.value
253        if (isRetried && chunks === 0 && chunk.kind === 'engine') {
254          held.push(chunk)
255        } else {
256          chunks += 1
257          for (const before of held.splice(0)) yield before
258          yield chunk
259        }
260        step = await stream.next()
261      }
262      const result = step.value
263      dump('turn.step', { chunks, held: held.length, isRetried, result })
264      if (result.usage !== null) keeper.learnModel(result.usage.model)
265      const isFailed = result.usage === null && result.stopReason === null
266      if (isFailed && model !== null) keeper.onFallbackFailed()
267      if (!isFailed || !isRetried || chunks > 0 || attempt >= keeper.config.maxRetries) {
268        for (const before of held) yield before
269        return result
270      }
271
272      const { rateLimits } = await $.session.usage()
273      const reset = rateLimits
274        .filter((w) => w.percentUsed >= 100 && w.resetsAt !== undefined)
275        .map((w) => Date.parse(w.resetsAt ?? ''))
276        .filter((t) => Number.isFinite(t))
277      const waitMs = reset.length > 0
278        ? Math.max(...reset) - Date.now() + keeper.config.marginSeconds * 1000
279        : overloadWaitMs(attempt, keeper.config.overload, Math.random)
280      if (Date.now() + waitMs > deadline) return result
281      write('INFO', `print run: request failed, sending it again in ${Math.round(waitMs / 1000)}s`, false)
282      await flushLog()
283      for (let left = Math.max(0, waitMs); left > 0; left -= 590_000) {
284        const seconds = Math.ceil(Math.min(left, 590_000) / 1000)
285        await $.process.run(['sleep', String(seconds)], { timeoutMs: (seconds + 10) * 1000 })
286      }
287    }
288  })
289
290  on('session.measure', async ($, e, next) => {
291    if (e.changed.includes('rateLimits')) await keeper.onMeasure()
292    return next(e)
293  })
294
295  on('prompt.submit', async ($, e, next) => {
296    dump('prompt.submit', { origin: e.origin, text: e.text.slice(0, 300) })
297    if (isPersonPrompt(e.origin.kind)) await keeper.onPersonPrompt()
298    else keeper.onDelivered(e.origin.kind)
299    return next(e)
300  })
301
302  on('tool.call', async ($, e, next) => {
303    const tool = String(e.tool)
304    if (tool !== 'ScheduleWakeup' && tool !== 'CronCreate' && tool !== 'CronDelete' && tool !== 'Workflow') return next(e)
305    const called = await next(e)
306    const result = 'result' in called && typeof called.result === 'object' && called.result !== null
307      ? (called.result as Record<string, unknown>)
308      : null
309    if (!('deny' in called && called.deny !== undefined) && called.isError !== true) {
310      keeper.onToolCall(await $.clock.now(), tool, e as unknown as Record<string, unknown>, result)
311      dump('tool.call', { tool, result })
312    }
313    return called
314  })
315
316  on('command.run', { command: 'rate-limit-options' }, async ($, e, next) => {
317    const now = await $.clock.now()
318    dump('command.run', { command: e.command, origin: e.origin, lastEditAt: keeper.core.activity.lastEditAt, now })
319    if (keeper.config.native.rateLimitMenu === 'show' || now - keeper.core.activity.lastEditAt < 10_000) return next(e)
320    const { rateLimits } = await $.session.usage()
321    const isLimited = keeper.core.usage !== null || rateLimits.some((w) => w.percentUsed >= 100)
322    if (!isLimited) return next(e)
323    // Claude Code opens this menu itself when it cannot arm its own
324    // auto-continue. Its default may be a paid option; answering it here is
325    // what "Stop and wait for limit to reset" does, and the wait is ours.
326    write('INFO', 'answered the usage-limit menu Claude Code opened: waiting for the reset', true)
327    return {}
328  })
329
330  on('prompt.edit', async ($, e, next) => {
331    keeper.onEdit(await $.clock.now())
332    return next(e)
333  })
334
335  on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
336    keeper.onNotice(await $.clock.now(), e.props.text)
337    return next(e)
338  })
339
340  on('session.append', async ($, e, next) => {
341    if (e.agentId === undefined && e.door !== 'response' && e.door !== 'tool-result' && e.door !== 'prompt') {
342      const text = textOf((e.message as { content?: unknown }).content)
343      const now = await $.clock.now()
344      if (WRAP_UP.test(text)) keeper.onWrapUpNotice(now)
345      if (keeper.onNotice(now, text) || WRAP_UP.test(text)) dump('session.append', { door: e.door, message: e.message })
346    }
347    return next(e)
348  })
349
350  on('tool.call', { tool: TOOL_NAME }, async ($, e) => {
351    const focus = typeof e.focus === 'string' ? e.focus : ''
352    return { result: await keeper.requestCompact(focus) }
353  })
354
355  on('command.run', { command: 'keep-going' }, async ($, e) => {
356    const [verb = 'status', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
357    switch (verb) {
358      case 'status': {
359        const view = (await $.state.get({ plugin: 'keep-going', key: 'view' } as const)).value
360        const pending = view?.line ?? 'nothing pending'
361        return { text: `${pending}${view?.detail ? `\n${view.detail}` : ''}\n\n${HELP}` }
362      }
363      case 'continue':
364        return { text: await keeper.continueNow() }
365      case 'cancel':
366        return { text: await keeper.cancel() }
367      case 'compact':
368        return { text: await keeper.requestCompact(rest.join(' ')) }
369      case 'pause':
370        keeper.core.isPaused = true
371        await keeper.save()
372        return { text: 'keep-going is paused for this session.' }
373      case 'resume':
374        keeper.core.isPaused = false
375        await keeper.save()
376        return { text: 'keep-going is watching this session again.' }
377      case 'debug':
378        return { text: JSON.stringify({ core: keeper.core, config: keeper.config }, null, 2) }
379      case 'log':
380        return { text: lines.slice(-20).join('\n') || 'Nothing logged yet.' }
381      default:
382        return { text: HELP }
383    }
384  })
385
386  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
387    if (!keeper.config.ui.band || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
388    const view = await read($, { plugin: 'keep-going', key: 'view' } as const)
389    if (view === null || view === undefined || view.actions.length === 0 || view.line === null) return next(e)
390
391    const { Box, Text, Button } = $.ui.resolve(e)
392    const act = (what: 'continue' | 'cancel' | 'compact' | 'skip') => async () => {
393      if (what === 'continue') await keeper.continueNow()
394      else if (what === 'compact') {
395        keeper.core.compact.scheduledFor = 0
396        keeper.core.compact.handledIdleSince = keeper.core.activity.idleSince
397        await keeper.compactNow(keeper.core.compact.request?.focus ?? keeper.config.compact.focus, 'from the band')
398        keeper.core.compact.request = null
399        await keeper.save()
400      } else await keeper.cancel()
401    }
402    const labels = { continue: 'Continue now', cancel: 'Cancel', compact: 'Compact now', skip: 'Skip' } as const
403
404    return (
405      <Box flexDirection="row" columnGap={1}>
406        <Text dimColor>keep-going: {view.line}</Text>
407        {view.actions.flatMap((what, i) => [
408          ...(i > 0 ? [<Text key={`${what}-bar`} dimColor>|</Text>] : []),
409          <Button key={what} label={labels[what]} plain onPress={act(what)} />,
410        ])}
411      </Box>
412    )
413  })
414}
415
hooks/backoff.ts 27 lines
1import type { Config } from './config'
2
3/**
4 * A 529 is global capacity, so every client that waits the same fixed
5 * schedule comes back in the same overload window. Full jitter spreads them
6 * over the whole interval; the floor keeps a retry from firing at once.
7 */
8const FULL_JITTER_FLOOR_MS = 5_000
9
10export function overloadWaitMs(attempt: number, overload: Config['overload'], random: () => number): number {
11  const seconds = overload.backoffSeconds[attempt] ?? overload.steadyStateSeconds
12  const base = seconds * 1000
13  if (overload.jitterMode === 'full') {
14    return Math.max(Math.min(FULL_JITTER_FLOOR_MS, base), Math.round(random() * base))
15  }
16  if (overload.jitterPct === 0) return base
17  const factor = 1 + (random() * 2 - 1) * (overload.jitterPct / 100)
18  return Math.max(0, Math.round(base * factor))
19}
20
21/**
22 * An overload retry more than this long after the last one starts a new
23 * incident with a fresh budget: otherwise one bad night's attempts count
24 * against an unrelated outage days later.
25 */
26export const INCIDENT_GAP_MS = 15 * 60_000
27
hooks/config.ts 270 lines
1export type CompactTrigger = 'request' | 'policy' | 'both'
2
3export type Config = {
4  enabled: boolean
5  maxRetries: number
6  marginSeconds: number
7  fallbackWaitHours: number
8  tickSeconds: number
9  retryMessage: string
10  /** Extra regexes, case-insensitive, that mark an error message as a usage limit. */
11  customPatterns: string[]
12  overload: {
13    enabled: boolean
14    backoffSeconds: number[]
15    steadyStateSeconds: number
16    jitterPct: number
17    jitterMode: 'full' | 'proportional'
18    maxTotalWaitMinutes: number
19    retryMessage: string
20  }
21  safeguard: BoundedRetry
22  streamInterrupted: BoundedRetry
23  nearLimitWrapUp: { enabled: boolean; maxRetries: number; retryMessage: string }
24  compact: {
25    enabled: boolean
26    trigger: CompactTrigger
27    window: { start: string; end: string } | null
28    awayMinutes: number | null
29    minContextPercent: number
30    minContextTokens: number | null
31    settle: { mode: 'before-expiry' | 'fixed'; marginSeconds: number; minutes: number }
32    cacheTtlMinutes: number | null
33    focus: string
34    matchLastMessage: boolean
35    minIntervalMinutes: number
36    waitForAgents: boolean
37  }
38  native: { usageLimit: 'defer' | 'ignore'; graceSeconds: number; rateLimitMenu: 'skip' | 'show' }
39  print: { enabled: boolean; maxWaitHours: number }
40  debug: { dumpEvents: boolean }
41  networkCheck: { enabled: boolean; url: string; maxWaitMinutes: number }
42  modelFallback: { enabled: boolean; map: Record<string, string>; switchBack: boolean }
43  ui: { statusLine: 'active' | 'always' | 'off'; band: boolean }
44}
45
46export type BoundedRetry = {
47  enabled: boolean
48  maxRetries: number
49  retryDelaySeconds: number
50  retryMessage: string
51}
52
53export const DEFAULTS: Config = {
54  enabled: true,
55  maxRetries: 5,
56  marginSeconds: 60,
57  fallbackWaitHours: 5,
58  tickSeconds: 5,
59  retryMessage: 'Continue where you left off. The previous attempt was rate limited.',
60  customPatterns: [],
61  overload: {
62    enabled: true,
63    backoffSeconds: [30, 60, 120, 240, 300],
64    steadyStateSeconds: 300,
65    jitterPct: 15,
66    jitterMode: 'full',
67    maxTotalWaitMinutes: 120,
68    retryMessage: 'Continue where you left off.',
69  },
70  safeguard: { enabled: true, maxRetries: 3, retryDelaySeconds: 8, retryMessage: 'continue' },
71  streamInterrupted: { enabled: true, maxRetries: 2, retryDelaySeconds: 5, retryMessage: 'continue' },
72  nearLimitWrapUp: { enabled: true, maxRetries: 3, retryMessage: 'continue' },
73  compact: {
74    enabled: false,
75    trigger: 'request',
76    window: null,
77    awayMinutes: null,
78    minContextPercent: 40,
79    minContextTokens: null,
80    settle: { mode: 'before-expiry', marginSeconds: 300, minutes: 4 },
81    cacheTtlMinutes: null,
82    focus: '',
83    matchLastMessage: false,
84    minIntervalMinutes: 30,
85    waitForAgents: true,
86  },
87  native: { usageLimit: 'defer', graceSeconds: 180, rateLimitMenu: 'skip' },
88  print: { enabled: true, maxWaitHours: 6 },
89  debug: { dumpEvents: false },
90  networkCheck: { enabled: true, url: 'https://api.anthropic.com/', maxWaitMinutes: 10 },
91  modelFallback: { enabled: false, map: { Opus: 'sonnet' }, switchBack: true },
92  ui: { statusLine: 'active', band: true },
93}
94
95type Raw = Record<string, unknown>
96
97const isObject = (v: unknown): v is Raw => typeof v === 'object' && v !== null && !Array.isArray(v)
98
99const num = (v: unknown, min: number, fallback: number) =>
100  typeof v === 'number' && Number.isFinite(v) && v >= min ? v : fallback
101
102const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
103
104const text = (v: unknown, fallback: string) => (typeof v === 'string' && v !== '' ? v : fallback)
105
106function oneOf<T extends string>(v: unknown, values: readonly T[], fallback: T): T {
107  return typeof v === 'string' && (values as readonly string[]).includes(v) ? (v as T) : fallback
108}
109
110const nullableNum = (v: unknown, min: number) =>
111  typeof v === 'number' && Number.isFinite(v) && v >= min ? v : null
112
113const block = (v: unknown): Raw => (isObject(v) ? v : {})
114
115const CLOCK = /^([01]?\d|2[0-3]):([0-5]\d)$/
116
117function boundedRetry(raw: unknown, d: BoundedRetry): BoundedRetry {
118  const b = block(raw)
119  return {
120    enabled: bool(b.enabled, d.enabled),
121    maxRetries: num(b.maxRetries, 1, d.maxRetries),
122    retryDelaySeconds: num(b.retryDelaySeconds, 1, d.retryDelaySeconds),
123    retryMessage: text(b.retryMessage, d.retryMessage),
124  }
125}
126
127export function validate(raw: unknown): Config {
128  const r = block(raw)
129  const d = DEFAULTS
130
131  const o = block(r.overload)
132  const backoff = Array.isArray(o.backoffSeconds)
133    ? o.backoffSeconds.filter((n): n is number => typeof n === 'number' && Number.isFinite(n) && n > 0)
134    : []
135
136  const c = block(r.compact)
137  const settle = block(c.settle)
138  const window = isObject(c.window) && CLOCK.test(String(c.window.start)) && CLOCK.test(String(c.window.end))
139    ? { start: String(c.window.start), end: String(c.window.end) }
140    : null
141
142  const nw = block(r.native)
143  const nc = block(r.networkCheck)
144  const mf = block(r.modelFallback)
145  const map = isObject(mf.map) ? mf.map : d.modelFallback.map
146  const ui = block(r.ui)
147
148  return {
149    enabled: bool(r.enabled, d.enabled),
150    maxRetries: num(r.maxRetries, 1, d.maxRetries),
151    marginSeconds: num(r.marginSeconds, 0, d.marginSeconds),
152    fallbackWaitHours: num(r.fallbackWaitHours, 0.1, d.fallbackWaitHours),
153    tickSeconds: num(r.tickSeconds ?? r.pollIntervalSeconds, 1, d.tickSeconds),
154    retryMessage: text(r.retryMessage, d.retryMessage),
155    customPatterns: Array.isArray(r.customPatterns)
156      ? r.customPatterns.filter((p): p is string => {
157          if (typeof p !== 'string' || p === '') return false
158          try {
159            new RegExp(p, 'i')
160            return true
161          } catch {
162            return false
163          }
164        })
165      : [],
166    overload: {
167      enabled: bool(o.enabled, d.overload.enabled),
168      backoffSeconds: backoff.length > 0 ? backoff : [...d.overload.backoffSeconds],
169      steadyStateSeconds: num(o.steadyStateSeconds, 1, d.overload.steadyStateSeconds),
170      jitterPct: Math.min(100, num(o.jitterPct, 0, d.overload.jitterPct)),
171      jitterMode: oneOf(o.jitterMode, ['full', 'proportional'], d.overload.jitterMode),
172      maxTotalWaitMinutes: num(o.maxTotalWaitMinutes, 0.1, d.overload.maxTotalWaitMinutes),
173      retryMessage: text(o.retryMessage, d.overload.retryMessage),
174    },
175    safeguard: boundedRetry(r.safeguard, d.safeguard),
176    streamInterrupted: boundedRetry(r.streamInterrupted, d.streamInterrupted),
177    nearLimitWrapUp: {
178      enabled: bool(block(r.nearLimitWrapUp).enabled, d.nearLimitWrapUp.enabled),
179      maxRetries: num(block(r.nearLimitWrapUp).maxRetries, 1, d.nearLimitWrapUp.maxRetries),
180      retryMessage: text(block(r.nearLimitWrapUp).retryMessage, d.nearLimitWrapUp.retryMessage),
181    },
182    compact: {
183      enabled: bool(c.enabled, d.compact.enabled),
184      trigger: oneOf(c.trigger, ['request', 'policy', 'both'], d.compact.trigger),
185      window,
186      awayMinutes: nullableNum(c.awayMinutes, 0),
187      minContextPercent: Math.min(100, num(c.minContextPercent, 0, d.compact.minContextPercent)),
188      minContextTokens: nullableNum(c.minContextTokens, 0),
189      settle: {
190        mode: oneOf(settle.mode, ['before-expiry', 'fixed'], d.compact.settle.mode),
191        marginSeconds: num(settle.marginSeconds, 0, d.compact.settle.marginSeconds),
192        minutes: num(settle.minutes, 0, d.compact.settle.minutes),
193      },
194      cacheTtlMinutes: nullableNum(c.cacheTtlMinutes, 1),
195      focus: typeof c.focus === 'string' ? c.focus : d.compact.focus,
196      matchLastMessage: bool(c.matchLastMessage, d.compact.matchLastMessage),
197      minIntervalMinutes: num(c.minIntervalMinutes, 0, d.compact.minIntervalMinutes),
198      waitForAgents: bool(c.waitForAgents, d.compact.waitForAgents),
199    },
200    native: {
201      usageLimit: oneOf(nw.usageLimit, ['defer', 'ignore'], d.native.usageLimit),
202      graceSeconds: num(nw.graceSeconds, 0, d.native.graceSeconds),
203      rateLimitMenu: oneOf(nw.rateLimitMenu, ['skip', 'show'], d.native.rateLimitMenu),
204    },
205    print: {
206      enabled: bool(block(r.print).enabled, d.print.enabled),
207      maxWaitHours: num(block(r.print).maxWaitHours, 0, d.print.maxWaitHours),
208    },
209    debug: { dumpEvents: bool(block(r.debug).dumpEvents, d.debug.dumpEvents) },
210    networkCheck: {
211      enabled: bool(nc.enabled, d.networkCheck.enabled),
212      url: typeof nc.url === 'string' && /^https?:\/\//.test(nc.url) ? nc.url : d.networkCheck.url,
213      maxWaitMinutes: num(nc.maxWaitMinutes, 0, d.networkCheck.maxWaitMinutes),
214    },
215    modelFallback: {
216      enabled: bool(mf.enabled, d.modelFallback.enabled),
217      switchBack: bool(mf.switchBack, d.modelFallback.switchBack),
218      map: Object.fromEntries(
219        Object.entries(map).filter(
220          (entry): entry is [string, string] =>
221            entry[0] !== '' && typeof entry[1] === 'string' && /^[\w.:[\]-]+$/.test(entry[1]),
222        ),
223      ),
224    },
225    ui: {
226      statusLine: oneOf(ui.statusLine, ['active', 'always', 'off'], d.ui.statusLine),
227      band: bool(ui.band, d.ui.band),
228    },
229  }
230}
231
232/**
233 * Two config files, merged one level deep with the user's on top: the
234 * system one (`$XDG_CONFIG_DIRS`, `/etc/xdg` by default) and the user's
235 * (`$XDG_CONFIG_HOME`, `~/.config` by default). The paths are the ones the
236 * claude-keep-going CLI reads, so an existing config keeps working.
237 */
238export function mergeFiles(files: readonly unknown[]): Raw {
239  const merged: Raw = {}
240  for (const file of files) {
241    if (!isObject(file)) continue
242    for (const [key, value] of Object.entries(file)) {
243      merged[key] = isObject(value) && isObject(merged[key])
244        ? { ...(merged[key] as Raw), ...value }
245        : value
246    }
247  }
248  return merged
249}
250
251/**
252 * The `/config` rows the manifest declares, laid over the file. `config`
253 * leaves the file's value alone.
254 */
255export function applyOptions(raw: Raw, options: Readonly<Record<string, unknown>>): Raw {
256  const out: Raw = { ...raw }
257  const compact = options.compact
258  if (compact === 'off') out.compact = { ...block(out.compact), enabled: false }
259  else if (compact === 'request' || compact === 'policy' || compact === 'both') {
260    out.compact = { ...block(out.compact), enabled: true, trigger: compact }
261  }
262  const fallback = options.modelFallback
263  if (fallback === 'on' || fallback === 'off') {
264    out.modelFallback = { ...block(out.modelFallback), enabled: fallback === 'on' }
265  }
266  const band = options.band
267  if (band === 'on' || band === 'off') out.ui = { ...block(out.ui), band: band === 'on' }
268  return out
269}
270
hooks/keeper.ts 821 lines
1import type { SessionRateLimit } from 'claude-code'
2
3import { overloadWaitMs, INCIDENT_GAP_MS } from './backoff'
4import { classify, nativeNoticeOf, type Failure } from './classify'
5import { decideCompact } from './compact'
6import { DEFAULTS, type Config } from './config'
7import { parseResetTime, calculateWaitMs } from './lib/time-parser.js'
8import { badgeOf, viewOf, clockOf, durationOf } from './view'
9import type { KeepGoingCore, KeepGoingRetryFamily, KeepGoingView } from '../types'
10
11/**
12 * What the keeper needs from the engine, as closures the hooks module builds
13 * inside its session.start hook (a module may only spell `$` at a call site).
14 */
15export type Io = {
16  now: () => Promise<number>
17  saveState: (core: KeepGoingCore, view: KeepGoingView | null) => Promise<void>
18  loadCore: () => Promise<KeepGoingCore | null | undefined>
19  status: (text: string | undefined) => void
20  /** Writes the badge a statusline script reads, '' when the session ends. */
21  writeBadge: (badge: string) => Promise<void>
22  storeGet: (key: string) => Promise<unknown>
23  storeSet: (key: string, value: unknown) => Promise<void>
24  storeKeys: () => Promise<string[]>
25  storeDelete: (key: string) => Promise<void>
26  rateLimits: () => Promise<readonly SessionRateLimit[]>
27  context: () => Promise<{ percent: number | null; tokens: number | null; isSubscription: boolean }>
28  model: () => Promise<string>
29  lastAssistantText: () => Promise<string>
30  /**
31   * When the prompt cache expires, from the statusline input a statusline
32   * script saved for this session; null when there is none. The mod API
33   * carries no cache state, the statusline input does.
34   */
35  cacheExpiresAt: () => Promise<{ expiresAt: number | null; isWarm: boolean } | null>
36  /** Subagents still working: pending or running, not a teammate sitting idle. */
37  busyAgents: () => Promise<number>
38  submit: (text: string) => Promise<void>
39  compact: (focus: string) => Promise<string | null>
40  isReachable: (url: string, timeoutMs: number) => Promise<boolean>
41  after: (ms: number, fn: () => void) => void
42}
43
44const STORE_MAX_AGE_MS = 8 * 24 * 3600_000
45const USAGE_RECHECK_MS = 30_000
46const NETWORK_RECHECK_MS = 15_000
47const NETWORK_TIMEOUT_MS = 5_000
48const FAILURE_SETTLE_MS = 1_500
49const WRAP_UP_DELAY_MS = 2_000
50
51/** Background tasks that run for the whole session and end without a Stop. */
52const LONG_LIVED_TASKS = new Set(['monitor', 'shell'])
53
54/** Prompts that mean the person is at the keyboard (or on their phone). */
55const PERSON_ORIGINS = new Set(['composer', 'bridge'])
56
57export function freshCore(): KeepGoingCore {
58  return {
59    usage: null,
60    retry: null,
61    fallback: null,
62    wrapUp: { noticeAt: 0, dueAt: 0, nudges: 0 },
63    compact: {
64      request: null,
65      lastSentAt: 0,
66      handledIdleSince: 0,
67      lastMatchedAnswer: null,
68      scheduledFor: 0,
69      reportedIdleSince: 0,
70    },
71    activity: {
72      isBusy: false,
73      turnStartedAt: 0,
74      idleSince: 0,
75      lastUserAt: 0,
76      background: null,
77      crons: 0,
78      permissionAt: 0,
79      lastAnswer: '',
80      failureTurnStartedAt: -1,
81      idleModel: '',
82      lastEditAt: 0,
83    },
84    scheduled: { recurringCrons: 0, oneShotCrons: 0, wakeUntil: 0, workflows: 0 },
85    models: {},
86    isPaused: false,
87    cacheTtlMs: null,
88  }
89}
90
91export type Logger = {
92  info: (line: string) => void
93  warn: (line: string) => void
94  /** A line in the transcript too, for what the person should see happen. */
95  notice: (line: string) => void
96}
97
98/**
99 * Everything the mod knows and does. Events record facts and set deadlines
100 * (a hook has a ten-second budget, so none of them waits); one timer reads
101 * the clock and acts on the deadlines. Comparing against the clock each tick
102 * also covers suspend: a wait that ran out while the laptop slept fires on
103 * the first tick after it wakes.
104 */
105export class Keeper {
106  core: KeepGoingCore = freshCore()
107  config: Config = DEFAULTS
108  sessionId = ''
109  random: () => number = Math.random
110  private isTicking = false
111  private lastView = ''
112  private lastBadge = ''
113  /** The latest auto-continue notice, which can come before the failure is classified. */
114  private lastNative: { notice: NonNullable<KeepGoingCore['usage']>['nativeNotice']; at: number } | null = null
115
116  io!: Io
117
118  constructor(readonly log: Logger) {}
119
120  async save(): Promise<void> {
121    const now = await this.io.now()
122    const view = viewOf(this.core, this.config, now)
123    const viewKey = JSON.stringify(view)
124    const isNewView = viewKey !== this.lastView
125    this.lastView = viewKey
126    await this.io.saveState(this.core, isNewView ? view : null)
127    if (isNewView) this.showStatus(view)
128    const badge = badgeOf(this.core, now)
129    if (badge !== this.lastBadge) {
130      this.lastBadge = badge
131      await this.io.writeBadge(badge).catch(() => {})
132    }
133    if (this.sessionId !== '') {
134      await this.io.storeSet(`session:${this.sessionId}`, {
135        savedAt: now,
136        usage: this.core.usage,
137        fallback: this.core.fallback,
138      })
139    }
140  }
141
142  private showStatus(view: KeepGoingView): void {
143    if (this.config.ui.statusLine === 'off') return
144    const isOnBand = this.config.ui.band && view.actions.length > 0
145    this.io.status(view.line === null || isOnBand ? undefined : `keep-going: ${view.line}`)
146  }
147
148  async restore(sessionId: string): Promise<void> {
149    this.sessionId = sessionId
150    const held = await this.io.loadCore()
151    if (held) {
152      const fresh = freshCore()
153      this.core = {
154        ...fresh,
155        ...held,
156        activity: { ...fresh.activity, ...held.activity },
157        scheduled: { ...fresh.scheduled, ...held.scheduled },
158        models: { ...held.models },
159      }
160      return
161    }
162    const models = await this.io.storeGet('models')
163    if (typeof models === 'object' && models !== null) this.core.models = { ...(models as Record<string, string>) }
164    const saved = await this.io.storeGet(`session:${sessionId}`)
165    if (typeof saved !== 'object' || saved === null) return
166    const { savedAt, usage, fallback } = saved as Partial<{ savedAt: number; usage: KeepGoingCore['usage']; fallback: KeepGoingCore['fallback'] }>
167    const now = await this.io.now()
168    if (typeof savedAt !== 'number' || now - savedAt > STORE_MAX_AGE_MS) return
169    this.core.usage = usage ?? null
170    this.core.fallback = fallback ?? null
171    if (this.core.usage !== null) this.log.info('picked up the usage wait from before the restart')
172  }
173
174  /** Drops this mod's store entries that are more than eight days old. */
175  async sweepStore(): Promise<void> {
176    const now = await this.io.now()
177    for (const key of await this.io.storeKeys()) {
178      if (!key.startsWith('session:')) continue
179      const value = await this.io.storeGet(key)
180      const savedAt = typeof value === 'object' && value !== null ? (value as { savedAt?: unknown }).savedAt : undefined
181      if (typeof savedAt !== 'number' || now - savedAt > STORE_MAX_AGE_MS) await this.io.storeDelete(key)
182    }
183  }
184
185  // Events
186
187  async onTurnStart(): Promise<void> {
188    const a = this.core.activity
189    a.isBusy = true
190    a.turnStartedAt = await this.io.now()
191    this.core.compact.scheduledFor = 0
192    if (this.core.usage !== null) this.core.usage.submittedAt = 0
193    await this.save()
194  }
195
196  async onTurnComplete(reason: string, answer: string): Promise<void> {
197    const now = await this.io.now()
198    const a = this.core.activity
199    a.isBusy = false
200    a.idleSince = now
201    a.lastAnswer = answer
202    a.idleModel = await this.io.model().catch(() => '')
203    if (reason === 'aborted') a.lastUserAt = now
204
205    if (reason === 'answer') {
206      this.onAnswered(now)
207    } else if (reason === 'aborted') {
208      if (this.core.usage !== null || this.core.retry !== null) {
209        this.log.info('turn interrupted, dropping the pending continue (user-interrupted)')
210        this.core.usage = null
211        this.core.retry = null
212      }
213    } else if (reason === 'refusal') {
214      await this.onFailure({ kind: 'safeguard', text: answer })
215    } else if (reason === 'error') {
216      const turnStartedAt = a.turnStartedAt
217      this.io.after(FAILURE_SETTLE_MS, () => {
218        void this.classifyLate(turnStartedAt, answer)
219      })
220    }
221    await this.save()
222  }
223
224  private onAnswered(now: number): void {
225    const u = this.core.usage
226    if (u !== null) {
227      const how = u.nativeNotice === 'fired' && u.attempts === 0 ? 'native-resumed' : 'resumed'
228      this.log.notice(`usage limit over, the session is working again (${how})`)
229      this.core.usage = null
230    }
231    const r = this.core.retry
232    if (r !== null) {
233      this.log.info(`${r.family} recovered after ${r.attempts} retries`)
234      this.core.retry = null
235    }
236    const f = this.core.fallback
237    if (f !== null && f.phase === 'continue') f.phase = 'active'
238
239    const w = this.core.wrapUp
240    const a = this.core.activity
241    if (w.noticeAt >= a.turnStartedAt && w.noticeAt !== 0) {
242      if (this.config.nearLimitWrapUp.enabled && w.nudges < this.config.nearLimitWrapUp.maxRetries) {
243        w.dueAt = now + WRAP_UP_DELAY_MS
244      } else if (w.nudges >= this.config.nearLimitWrapUp.maxRetries) {
245        this.log.warn('wrap-up nudge: gave up')
246      }
247    } else {
248      w.nudges = 0
249      w.dueAt = 0
250    }
251  }
252
253  /**
254   * A turn that ended in an error when no StopFailure reached us for it: the
255   * classic event only fires while some hook is registered for it, so this
256   * reads the error message the turn ended on instead.
257   */
258  private async classifyLate(turnStartedAt: number, answer: string): Promise<void> {
259    if (this.core.activity.failureTurnStartedAt === turnStartedAt) return
260    // A response cut off partway ends the turn with the partial text as its
261    // answer; the error is the message after it.
262    const last = await this.io.lastAssistantText().catch(() => '')
263    const failure = classify(null, last, this.config.customPatterns) ?? classify(null, answer, this.config.customPatterns)
264    const text = last || answer
265    if (failure === null) {
266      this.log.info(`turn ended in an error that retrying won't fix: ${text.slice(0, 200)}`)
267      return
268    }
269    await this.onFailure(failure)
270    await this.save()
271  }
272
273  async onStopFailure(error: string, text: string): Promise<void> {
274    this.log.info(`StopFailure: ${error}`)
275    const failure = classify(error, text, this.config.customPatterns)
276    if (failure === null) {
277      this.log.info(`StopFailure ${error}: not retryable`)
278      return
279    }
280    await this.onFailure(failure)
281    await this.save()
282  }
283
284  async onFailure(failure: Failure): Promise<void> {
285    const a = this.core.activity
286    if (a.failureTurnStartedAt === a.turnStartedAt) return
287    a.failureTurnStartedAt = a.turnStartedAt
288    const now = await this.io.now()
289    this.log.info(`failure: ${failure.kind}: ${failure.text.slice(0, 300)}`)
290
291    if (failure.kind === 'usage') {
292      if (failure.model !== null && await this.startFallback(failure.model, failure.text, now)) return
293      await this.enterUsageWait(failure.text, now)
294      return
295    }
296    if (this.core.usage !== null) return
297    this.retryFailure(failure.kind, failure.text, now)
298  }
299
300  private async resetFromRateLimits(): Promise<number | null> {
301    const rateLimits = await this.io.rateLimits()
302    const resets = rateLimits
303      .filter((w) => w.percentUsed >= 100 && w.resetsAt !== undefined)
304      .map((w) => Date.parse(w.resetsAt ?? ''))
305      .filter((t) => Number.isFinite(t))
306    return resets.length > 0 ? Math.max(...resets) : null
307  }
308
309  private async enterUsageWait(banner: string, now: number): Promise<void> {
310    const margin = this.config.marginSeconds * 1000
311    let until: number
312    let isFallback = false
313    const fromApi = await this.resetFromRateLimits()
314    const parsed = parseResetTime(banner)
315    if (fromApi !== null && fromApi > now - 3600_000) {
316      until = Math.max(now, fromApi) + margin
317    } else if (parsed !== null) {
318      until = now + calculateWaitMs(parsed, this.config.marginSeconds, this.config.fallbackWaitHours, new Date(now))
319    } else {
320      until = now + this.config.fallbackWaitHours * 3600_000 + margin
321      isFallback = true
322    }
323
324    this.core.retry = null
325    const u = this.core.usage
326    if (u !== null) {
327      u.until = until
328      u.isFallback = isFallback
329      u.banner = banner
330      u.submittedAt = 0
331    } else {
332      this.core.usage = {
333        until,
334        enteredAt: now,
335        isFallback,
336        attempts: 0,
337        banner,
338        submittedAt: 0,
339        nativeNotice: null,
340        nativeNoticeAt: 0,
341        hasLoggedGrace: false,
342        hasLoggedMissed: false,
343        networkDownSince: 0,
344        isGivenUp: false,
345      }
346    }
347    const native = this.lastNative
348    const wait = this.core.usage
349    if (wait !== null && native !== null && native.at >= this.core.activity.turnStartedAt && wait.nativeNotice === null) {
350      wait.nativeNotice = native.notice
351      wait.nativeNoticeAt = native.at
352    }
353    const source = fromApi !== null ? 'the API' : parsed !== null ? 'the banner' : 'no reset time, the fallback wait'
354    this.log.notice(`usage limit: continuing at ${clockOf(until)}, in ${durationOf(until - now)} (from ${source})`)
355  }
356
357  /** A wait taken from `fallbackWaitHours` moves to the real reset once one is known. */
358  async onMeasure(): Promise<void> {
359    const u = this.core.usage
360    if (u === null || !u.isFallback) return
361    const reset = await this.resetFromRateLimits()
362    if (reset === null) return
363    u.until = reset + this.config.marginSeconds * 1000
364    u.isFallback = false
365    this.log.info(`usage wait corrected to ${clockOf(u.until)} from the rate-limit windows`)
366    await this.save()
367  }
368
369  private retryFailure(family: KeepGoingRetryFamily, detail: string, now: number): void {
370    const cfg = family === 'overload' ? this.config.overload
371      : family === 'safeguard' ? this.config.safeguard
372        : this.config.streamInterrupted
373    if (!cfg.enabled) {
374      this.log.info(`${family}: retry turned off`)
375      return
376    }
377    let r = this.core.retry
378    const isNewIncident = r === null || r.family !== family
379      || (family === 'overload' && r.lastSentAt !== 0 && now - r.lastSentAt > INCIDENT_GAP_MS)
380    if (r === null || isNewIncident) {
381      r = { family, attempts: 0, until: 0, startedAt: now, totalWaitMs: 0, lastSentAt: 0, isAwaitingResult: false, isGivenUp: false, detail }
382      this.core.retry = r
383    }
384    r.detail = detail
385    r.isAwaitingResult = false
386
387    const max = family === 'overload' ? this.config.maxRetries : (cfg as Config['safeguard']).maxRetries
388    if (r.attempts >= max) {
389      if (!r.isGivenUp) this.log.notice(`${family}: gave up after ${r.attempts} retries`)
390      r.isGivenUp = true
391      return
392    }
393    const wait = family === 'overload'
394      ? overloadWaitMs(r.attempts, this.config.overload, this.random)
395      : (cfg as Config['safeguard']).retryDelaySeconds * 1000
396    if (family === 'overload' && r.totalWaitMs + wait > this.config.overload.maxTotalWaitMinutes * 60_000) {
397      if (!r.isGivenUp) this.log.notice(`overload: gave up, ${Math.round(r.totalWaitMs / 60_000)} minutes of waiting already`)
398      r.isGivenUp = true
399      return
400    }
401    r.totalWaitMs += wait
402    r.until = now + wait
403    this.log.info(`${family}: retry ${r.attempts + 1} in ${durationOf(wait)}`)
404  }
405
406  private async startFallback(model: string, banner: string, now: number): Promise<boolean> {
407    const mf = this.config.modelFallback
408    if (!mf.enabled) return false
409    const key = Object.keys(mf.map).find((k) => k.toLowerCase() === model.toLowerCase())
410    const alias = key === undefined ? undefined : mf.map[key]
411    if (alias === undefined) return false
412    const f = this.core.fallback
413    if (f !== null && f.from.toLowerCase() === model.toLowerCase()) return false
414    const parsed = parseResetTime(banner)
415    const resetAt = parsed !== null
416      ? now + calculateWaitMs(parsed, this.config.marginSeconds, this.config.fallbackWaitHours, new Date(now))
417      : now + this.config.fallbackWaitHours * 3600_000
418    const to = this.core.models[alias.toLowerCase()] ?? modelIdOf(alias)
419    this.core.fallback = { from: model, to, original: await this.io.model(), resetAt, phase: 'switch', banner }
420    this.core.retry = null
421    this.log.notice(`${model} limit: sending its requests to ${to} until ${clockOf(resetAt)}`)
422    return true
423  }
424
425  /**
426   * The model a request should go to: the fallback while one model's limit
427   * holds, for the main loop and subagents alike. Rewriting each request
428   * leaves /model and the saved default alone, which a /model switch would
429   * not: it saves the model as the default for new sessions.
430   */
431  stepModel(model: string, now: number): string | null {
432    const f = this.core.fallback
433    if (f === null || f.phase === 'restore' || now >= f.resetAt) return null
434    return model.toLowerCase().includes(f.from.toLowerCase()) ? f.to : null
435  }
436
437  onNotification(now: number, type: string): void {
438    if (type === 'permission_prompt') this.core.activity.permissionAt = now
439    const u = this.core.usage
440    if (u === null) return
441    const notice = type === 'quota_auto_resume_fired' ? 'fired'
442      : type === 'quota_auto_resume_stale' ? 'stale'
443        : type === 'quota_auto_resume_disabled' ? 'disabled' : null
444    if (notice === null) return
445    u.nativeNotice = notice
446    u.nativeNoticeAt = now
447    this.log.info(`Claude Code auto-continue: ${notice}`)
448  }
449
450  onStop(background: number | null, crons: number): void {
451    this.core.activity.background = background
452    this.core.activity.crons = crons
453  }
454
455  async onPersonPrompt(): Promise<void> {
456    const now = await this.io.now()
457    this.core.activity.lastUserAt = now
458    const a = this.core.activity
459    if (!a.isBusy && (this.core.usage !== null || this.core.retry !== null)) {
460      this.log.info('the person sent a prompt, dropping the pending continue (user-continued)')
461      this.core.usage = null
462      this.core.retry = null
463    }
464    this.core.wrapUp.dueAt = 0
465    await this.save()
466  }
467
468  /** A notice row or line Claude Code showed; true when it was about its own auto-continue. */
469  onNotice(now: number, text: string): boolean {
470    const notice = nativeNoticeOf(text)
471    if (notice === null) return false
472    this.lastNative = { notice, at: now }
473    const u = this.core.usage
474    if (u !== null && u.nativeNotice !== notice) {
475      u.nativeNotice = notice
476      u.nativeNoticeAt = now
477      this.log.info(`Claude Code auto-continue: ${notice}`)
478    }
479    return true
480  }
481
482  onEdit(now: number): void {
483    this.core.activity.lastUserAt = now
484    this.core.activity.lastEditAt = now
485  }
486
487  /**
488   * Scheduled work, from the tool calls that set it up: the Stop hook that
489   * used to report it never reaches a mod.
490   */
491  onToolCall(now: number, tool: string, input: Record<string, unknown>, result: Record<string, unknown> | null): void {
492    const s = this.core.scheduled
493    if (tool === 'ScheduleWakeup') {
494      if (input.stop === true) s.wakeUntil = 0
495      else if (result !== null && typeof result.scheduledFor === 'number') s.wakeUntil = Math.max(s.wakeUntil, result.scheduledFor + 60_000)
496      else if (typeof input.delaySeconds === 'number') s.wakeUntil = Math.max(s.wakeUntil, now + input.delaySeconds * 1000 + 60_000)
497    } else if (tool === 'CronCreate') {
498      const isRecurring = result !== null && typeof result.recurring === 'boolean' ? result.recurring : input.recurring !== false
499      if (isRecurring) s.recurringCrons += 1
500      else s.oneShotCrons += 1
501    } else if (tool === 'CronDelete') {
502      if (s.recurringCrons > 0) s.recurringCrons -= 1
503      else if (s.oneShotCrons > 0) s.oneShotCrons -= 1
504    } else if (tool === 'Workflow') {
505      s.workflows += 1
506    }
507  }
508
509  /** A scheduled prompt or a task's notification arrived: one less thing to wait for. */
510  onDelivered(kind: string): void {
511    const s = this.core.scheduled
512    if (kind === 'scheduled-trigger' && s.oneShotCrons > 0 && s.recurringCrons === 0) s.oneShotCrons -= 1
513    if (kind === 'task-notification' && s.workflows > 0) s.workflows -= 1
514  }
515
516  pendingSchedules(now: number): number {
517    const s = this.core.scheduled
518    return s.recurringCrons + s.oneShotCrons + s.workflows + (s.wakeUntil > now ? 1 : 0)
519  }
520
521  /** A model id that answered, so an alias in modelFallback.map can name it. */
522  learnModel(id: string): void {
523    const family = /^claude-([a-z]+)-/.exec(id)?.[1]
524    if (family === undefined || this.core.models[family] === id) return
525    this.core.models[family] = id
526    void this.io.storeSet('models', this.core.models).catch(() => {})
527  }
528
529  /** A request sent to the fallback model got no answer: stop sending it there. */
530  onFallbackFailed(): void {
531    const f = this.core.fallback
532    if (f === null) return
533    this.log.notice(`${f.to} did not answer, waiting for the ${f.from} limit instead`)
534    this.core.fallback = null
535    void this.io.now().then((now) => this.enterUsageWait(f.banner, now))
536  }
537
538  onWrapUpNotice(now: number): void {
539    this.core.wrapUp.noticeAt = now
540    this.log.info('near-limit wrap-up notice seen')
541  }
542
543  // The timer
544
545  async tick(): Promise<void> {
546    if (this.isTicking) return
547    this.isTicking = true
548    try {
549      await this.step()
550      await this.save()
551    } catch (error) {
552      this.log.warn(`tick failed: ${error instanceof Error ? error.message : String(error)}`)
553    } finally {
554      this.isTicking = false
555    }
556  }
557
558  private async step(): Promise<void> {
559    if (!this.config.enabled || this.core.isPaused) return
560    const now = await this.io.now()
561    const a = this.core.activity
562
563    if (await this.stepUsage(now)) return
564    if (await this.stepFallback(now)) return
565    if (a.isBusy) return
566    if (await this.stepRetry(now)) return
567    if (await this.stepWrapUp(now)) return
568    await this.stepCompact(now)
569  }
570
571  private async stepUsage(now: number): Promise<boolean> {
572    const u = this.core.usage
573    if (u === null) return false
574    if (u.isGivenUp || now < u.until) return true
575    if (this.core.activity.isBusy) return true
576    if (u.submittedAt !== 0 && now - u.submittedAt < USAGE_RECHECK_MS * 4) return true
577
578    const native = this.config.native
579    if (native.usageLimit === 'defer' && u.attempts === 0) {
580      const isNativeOff = (u.nativeNotice === 'stale' || u.nativeNotice === 'disabled') && u.nativeNoticeAt >= u.enteredAt
581      if (!isNativeOff) {
582        if (now < u.until + native.graceSeconds * 1000) {
583          if (!u.hasLoggedGrace) {
584            u.hasLoggedGrace = true
585            this.log.info(`reset passed, giving Claude Code's auto-continue ${native.graceSeconds}s (native-grace)`)
586          }
587          return true
588        }
589        if (!u.hasLoggedMissed) {
590          u.hasLoggedMissed = true
591          this.log.warn('Claude Code did not continue on its own (native-missed)')
592        }
593      }
594    }
595
596    if (u.attempts >= this.config.maxRetries) {
597      u.isGivenUp = true
598      this.log.notice(`usage limit: gave up after ${u.attempts} continues`)
599      return true
600    }
601
602    if (this.config.networkCheck.enabled && !(await this.isReachable())) {
603      if (u.networkDownSince === 0) {
604        u.networkDownSince = now
605        this.log.warn('API unreachable, holding the continue (network-down)')
606      }
607      if (now - u.networkDownSince < this.config.networkCheck.maxWaitMinutes * 60_000) {
608        u.until = now + NETWORK_RECHECK_MS
609        return true
610      }
611      this.log.warn('API still unreachable, continuing anyway')
612    }
613    u.networkDownSince = 0
614
615    u.attempts += 1
616    u.until = now + USAGE_RECHECK_MS
617    u.submittedAt = now
618    this.log.notice(`usage limit reset: sending continue ${u.attempts}/${this.config.maxRetries}`)
619    await this.submit(this.config.retryMessage)
620    return true
621  }
622
623  private async stepFallback(now: number): Promise<boolean> {
624    const f = this.core.fallback
625    if (f === null) return false
626    if (now >= f.resetAt) {
627      this.log.notice(`${f.from} limit reset: its requests go to ${f.from} again`)
628      this.core.fallback = null
629      return false
630    }
631    if (f.phase !== 'switch' || this.core.activity.isBusy) return false
632    f.phase = 'continue'
633    await this.submit(this.config.retryMessage)
634    return true
635  }
636
637  private async stepRetry(now: number): Promise<boolean> {
638    const r = this.core.retry
639    if (r === null || r.isGivenUp || r.isAwaitingResult) return r !== null && !r.isGivenUp
640    if (now < r.until) return true
641    const message = r.family === 'overload' ? this.config.overload.retryMessage
642      : r.family === 'safeguard' ? this.config.safeguard.retryMessage
643        : this.config.streamInterrupted.retryMessage
644    r.attempts += 1
645    r.lastSentAt = now
646    r.isAwaitingResult = true
647    this.log.notice(`${r.family}: sending retry ${r.attempts}`)
648    await this.submit(message)
649    return true
650  }
651
652  private async stepWrapUp(now: number): Promise<boolean> {
653    const w = this.core.wrapUp
654    if (w.dueAt === 0 || now < w.dueAt) return false
655    w.dueAt = 0
656    w.nudges += 1
657    this.log.notice(`near-limit wrap-up: sending continue ${w.nudges}/${this.config.nearLimitWrapUp.maxRetries}`)
658    await this.submit(this.config.nearLimitWrapUp.retryMessage)
659    return true
660  }
661
662  private async stepCompact(now: number): Promise<void> {
663    const c = this.core.compact
664    const a = this.core.activity
665    const context = await this.io.context()
666    const decision = decideCompact(this.config.compact, {
667      now,
668      idleSince: a.idleSince,
669      isBusy: a.isBusy,
670      handledIdleSince: c.handledIdleSince,
671      background: a.background ?? await this.io.busyAgents(),
672      crons: Math.max(a.crons, this.pendingSchedules(now)),
673      permissionAt: a.permissionAt,
674      lastSentAt: c.lastSentAt,
675      lastAnswer: a.lastAnswer,
676      lastMatchedAnswer: c.lastMatchedAnswer,
677      request: c.request,
678      context: { percent: context.percent, tokens: context.tokens },
679      lastUserAt: a.lastUserAt,
680      cacheTtlMs: this.cacheTtlMs(context.isSubscription),
681      cacheExpiresAt: await this.cacheExpiry(),
682    })
683
684    if (decision.action === 'none') {
685      c.scheduledFor = 0
686      return
687    }
688    if (decision.action !== 'cold' && a.idleModel !== '' && (await this.io.model().catch(() => a.idleModel)) !== a.idleModel) {
689      c.handledIdleSince = a.idleSince
690      c.scheduledFor = 0
691      this.log.info('compaction skipped: the model changed since the last turn, so its cache is cold (compact-skipped-cold)')
692      return
693    }
694    if (decision.action === 'cold') {
695      c.handledIdleSince = a.idleSince
696      c.scheduledFor = 0
697      this.log.info('compaction skipped: the prompt cache has already expired (compact-skipped-cold)')
698      return
699    }
700    if (decision.action === 'wait') {
701      c.scheduledFor = decision.fireAt
702      if (c.reportedIdleSince !== a.idleSince) {
703        c.reportedIdleSince = a.idleSince
704        this.log.info(`compaction (${decision.trigger}) scheduled for ${clockOf(decision.fireAt)}`)
705      }
706      return
707    }
708
709    c.handledIdleSince = a.idleSince
710    c.lastSentAt = now
711    c.scheduledFor = 0
712    if (decision.trigger === 'last-message') c.lastMatchedAnswer = a.lastAnswer
713    if (decision.trigger === 'request') c.request = null
714    await this.compactNow(decision.focus, decision.trigger)
715  }
716
717  async compactNow(focus: string, why: string): Promise<void> {
718    this.log.notice(`compacting (${why})${focus ? `: ${focus}` : ''}`)
719    try {
720      const skip = await this.io.compact(focus)
721      if (skip !== null) this.log.info(`compaction skipped by a hook: ${skip}`)
722    } catch (error) {
723      this.log.warn(`compaction failed: ${error instanceof Error ? error.message : String(error)}`)
724    }
725  }
726
727  /** The statusline's expiry when one was saved after the last turn ended; null to estimate. */
728  private async cacheExpiry(): Promise<number | null> {
729    const saved = await this.io.cacheExpiresAt().catch(() => null)
730    if (saved === null) return null
731    if (!saved.isWarm) return 0
732    if (saved.expiresAt === null || saved.expiresAt < this.core.activity.idleSince) return null
733    return saved.expiresAt
734  }
735
736  cacheTtlMs(isSubscription: boolean): number {
737    const minutes = this.config.compact.cacheTtlMinutes
738    if (minutes !== null) return minutes * 60_000
739    if (this.core.cacheTtlMs !== null) return this.core.cacheTtlMs
740    return (isSubscription ? 60 : 5) * 60_000
741  }
742
743  private async submit(text: string): Promise<void> {
744    try {
745      await this.io.submit(text)
746    } catch (error) {
747      this.log.warn(`could not send "${text}": ${error instanceof Error ? error.message : String(error)}`)
748    }
749  }
750
751  private async isReachable(): Promise<boolean> {
752    return this.io.isReachable(this.config.networkCheck.url, NETWORK_TIMEOUT_MS)
753  }
754
755  // Person's actions, from the command and the band
756
757  async continueNow(): Promise<string> {
758    const now = await this.io.now()
759    if (this.core.usage !== null) {
760      this.core.usage.until = now
761      this.core.usage.isGivenUp = false
762      this.core.usage.attempts = Math.max(this.core.usage.attempts, 1)
763      this.core.usage.submittedAt = 0
764    } else if (this.core.retry !== null) {
765      this.core.retry.until = now
766      this.core.retry.isGivenUp = false
767      this.core.retry.isAwaitingResult = false
768    } else {
769      return 'Nothing is waiting.'
770    }
771    await this.tick()
772    return 'Sent the continue.'
773  }
774
775  async cancel(): Promise<string> {
776    const had = this.core.usage !== null || this.core.retry !== null || this.core.compact.scheduledFor !== 0
777    this.core.usage = null
778    this.core.retry = null
779    this.core.wrapUp.dueAt = 0
780    this.core.compact.handledIdleSince = this.core.activity.idleSince
781    this.core.compact.scheduledFor = 0
782    await this.save()
783    return had ? 'Dropped what was pending for this session.' : 'Nothing was pending.'
784  }
785
786  async requestCompact(focus: string): Promise<string> {
787    const now = await this.io.now()
788    if (!this.config.compact.enabled || this.config.compact.trigger === 'policy') {
789      return 'Compaction requests are off. Set compact.enabled and compact.trigger to "request" or "both".'
790    }
791    this.core.compact.request = { at: now, focus }
792    if (this.core.compact.handledIdleSince === this.core.activity.idleSince) this.core.compact.handledIdleSince = 0
793    await this.save()
794    const ttl = this.cacheTtlMs((await this.io.context()).isSubscription)
795    const margin = this.config.compact.settle.mode === 'before-expiry'
796      ? ttl - this.config.compact.settle.marginSeconds * 1000
797      : this.config.compact.settle.minutes * 60_000
798    return `Compaction requested. It runs once the session has been idle for ${durationOf(margin)}${focus ? `, keeping: ${focus}` : ''}.`
799  }
800}
801
802/**
803 * What an alias in modelFallback.map stands for: a request rewritten by a
804 * hook goes out as named, so an alias has to be a full id by then.
805 */
806const MODEL_IDS: Record<string, string> = {
807  opus: 'claude-opus-5-5',
808  sonnet: 'claude-sonnet-5-5',
809  haiku: 'claude-haiku-4-5-20251001',
810  fable: 'claude-fable-5-1',
811}
812
813export const modelIdOf = (name: string) => MODEL_IDS[name.toLowerCase()] ?? name
814
815export const isPersonPrompt = (kind: string) => PERSON_ORIGINS.has(kind)
816
817export function backgroundOf(tasks: unknown): number | null {
818  if (!Array.isArray(tasks)) return null
819  return tasks.filter((t: { type?: unknown }) => !LONG_LIVED_TASKS.has(String(t?.type))).length
820}
821
hooks/classify.ts 75 lines
1export type Failure =
2  | { kind: 'usage'; text: string; model: string | null }
3  | { kind: 'overload'; text: string }
4  | { kind: 'interrupted'; text: string }
5  | { kind: 'safeguard'; text: string }
6
7/**
8 * Limits that cover every model: switching models would not help, so these
9 * always wait for the reset.
10 */
11const ACCOUNT_WIDE = new Set([
12  'session', 'weekly', 'monthly', 'usage', 'fast', 'spend', 'credit', 'channel', 'team',
13])
14
15/**
16 * The model a limit is scoped to, from Claude Code's banner: "You've hit your
17 * Opus limit · resets Oct 9, 10am", "You've reached your Fable limit. Run
18 * /usage-credits ...". Null for an account-wide limit.
19 */
20export function scopedModel(text: string): string | null {
21  const m = /(?:hit|reached) your ([A-Za-z][\w.-]*) limit/i.exec(text)
22  const name = m?.[1]
23  if (name === undefined || ACCOUNT_WIDE.has(name.toLowerCase())) return null
24  return name
25}
26
27/**
28 * Every truncated-stream message Claude Code writes ends with one of these.
29 * They all come from one stream finalizer and carry `error: server_error`,
30 * so the text is what tells them from a real 5xx.
31 */
32const INTERRUPTED = /The response above may be incomplete\.|before a response was produced\. Try again\.|and no response was produced\. Try again\./
33
34const SAFEGUARD = /safeguards flagged this message/
35
36const USAGE = /(?:hit|reached) your [\w.-]+ limit|usage limit reached|out of (?:extra )?usage|limit reached\s*[·-]\s*resets/i
37
38const API_LIMITED = /temporarily limiting requests/
39
40/**
41 * One failed turn, from what Claude Code reported: the StopFailure `error`
42 * kind when the classic event carried one, and the error message it showed.
43 * Null for a failure retrying cannot fix (auth, billing, a bad request).
44 */
45export function classify(error: string | null, text: string, customPatterns: readonly string[] = []): Failure | null {
46  if (customPatterns.some((p) => new RegExp(p, 'i').test(text))) return { kind: 'usage', text, model: null }
47  if (SAFEGUARD.test(text)) return { kind: 'safeguard', text }
48  if (INTERRUPTED.test(text)) return { kind: 'interrupted', text }
49  if (error === 'rate_limit' || (error === null && USAGE.test(text))) {
50    if (API_LIMITED.test(text)) return { kind: 'overload', text }
51    return { kind: 'usage', text, model: scopedModel(text) }
52  }
53  if (error === 'overloaded' || error === 'server_error') return { kind: 'overload', text }
54  if (error === null && /^API Error: (?:5\d\d|529|429)\b|overloaded_error|temporarily limiting requests/m.test(text)) {
55    return { kind: 'overload', text }
56  }
57  return null
58}
59
60/**
61 * What a notice of Claude Code's own auto-continue says, from its text:
62 * "Usage limit reached · continuing automatically at 9:42am", "Usage limit
63 * reset · continuing automatically", "Usage limit has reset · press enter to
64 * continue", "Automatic continue was turned off · this task will not resume
65 * on its own", and the variants for a reset more than a day out or repeated
66 * hits. Null for any other text.
67 */
68export function nativeNoticeOf(text: string): 'armed' | 'fired' | 'stale' | 'disabled' | null {
69  if (/will not resume on its own|Automatic continue (?:cancelled|stopped|was turned off)/i.test(text)) return 'disabled'
70  if (/press enter to continue/i.test(text) && /usage limit/i.test(text)) return 'stale'
71  if (/Usage limit (?:reset|available again)[^\n]*continuing (?:automatically|now)/i.test(text)) return 'fired'
72  if (/continuing automatically (?:at|in|shortly)/i.test(text)) return 'armed'
73  return null
74}
75
hooks/compact.ts 99 lines
1import type { Config } from './config'
2
3export type CompactRequest = { at: number; focus: string }
4
5export type CompactInputs = {
6  now: number
7  /** When the main loop's last turn ended; 0 before the first. */
8  idleSince: number
9  isBusy: boolean
10  /** The idle period already acted on (sent, or skipped as cold). */
11  handledIdleSince: number
12  /** Background work the last Stop reported, monitors and shells left out; null when it said nothing. */
13  background: number | null
14  crons: number
15  permissionAt: number
16  lastSentAt: number
17  lastAnswer: string
18  lastMatchedAnswer: string | null
19  request: CompactRequest | null
20  context: { percent: number | null; tokens: number | null }
21  lastUserAt: number
22  cacheTtlMs: number
23  /** The statusline's expiry for this idle period (0 when it says the cache is cold); null to estimate from the TTL. */
24  cacheExpiresAt: number | null
25}
26
27export type CompactTriggerKind = 'request' | 'policy' | 'last-message'
28
29export type CompactDecision =
30  | { action: 'none' }
31  | { action: 'cold' }
32  | { action: 'wait'; fireAt: number; trigger: CompactTriggerKind; focus: string }
33  | { action: 'fire'; trigger: CompactTriggerKind; focus: string }
34
35const REQUEST_MAX_AGE_MS = 24 * 3600_000
36const LAST_MESSAGE = /(^|[^\w/])\/compact\b/
37
38function minutesOfDay(hhmm: string): number {
39  const [h = 0, m = 0] = hhmm.split(':').map(Number)
40  return h * 60 + m
41}
42
43/** start <= now < end in local time; 22:00-06:00 wraps past midnight. */
44export function inWindow(window: Config['compact']['window'], date: Date): boolean {
45  if (window === null) return true
46  const now = date.getHours() * 60 + date.getMinutes()
47  const start = minutesOfDay(window.start)
48  const end = minutesOfDay(window.end)
49  return start <= end ? now >= start && now < end : now >= start || now < end
50}
51
52function triggerOf(c: Config['compact'], i: CompactInputs): { kind: CompactTriggerKind; focus: string } | null {
53  if ((c.trigger === 'request' || c.trigger === 'both')
54      && i.request !== null && i.now - i.request.at < REQUEST_MAX_AGE_MS) {
55    return { kind: 'request', focus: i.request.focus || c.focus }
56  }
57  if (c.trigger === 'policy' || c.trigger === 'both') {
58    const { percent, tokens } = i.context
59    const isPercentOk = percent !== null && percent >= c.minContextPercent
60    const isTokensOk = c.minContextTokens === null || (tokens !== null && tokens >= c.minContextTokens)
61    if (isPercentOk && isTokensOk) return { kind: 'policy', focus: c.focus }
62  }
63  if (c.matchLastMessage && i.lastAnswer !== '' && i.lastAnswer !== i.lastMatchedAnswer
64      && LAST_MESSAGE.test(i.lastAnswer)) {
65    return { kind: 'last-message', focus: c.focus }
66  }
67  return null
68}
69
70/**
71 * Whether this idle period should compact, and when. A compaction re-reads
72 * the whole context once: done while the prompt cache is warm that read is
73 * billed at the cache rate, while after expiry the next message re-sends it
74 * all cold anyway. So the default fires `settle.marginSeconds` before the
75 * cache expires.
76 */
77export function decideCompact(c: Config['compact'], i: CompactInputs): CompactDecision {
78  if (!c.enabled || i.isBusy || i.idleSince === 0) return { action: 'none' }
79  if (i.handledIdleSince === i.idleSince) return { action: 'none' }
80  if (c.waitForAgents && (i.background === null || i.background > 0)) return { action: 'none' }
81  if (i.crons > 0) return { action: 'none' }
82  if (i.permissionAt > i.idleSince) return { action: 'none' }
83  if (i.now - i.lastSentAt < c.minIntervalMinutes * 60_000) return { action: 'none' }
84
85  const trigger = triggerOf(c, i)
86  if (trigger === null) return { action: 'none' }
87  if (!inWindow(c.window, new Date(i.now))) return { action: 'none' }
88  if (c.awayMinutes !== null && i.now - i.lastUserAt < c.awayMinutes * 60_000) return { action: 'none' }
89
90  const expiresAt = i.cacheExpiresAt ?? i.idleSince + i.cacheTtlMs
91  if (i.now >= expiresAt) return { action: 'cold' }
92
93  const fireAt = c.settle.mode === 'before-expiry'
94    ? expiresAt - c.settle.marginSeconds * 1000
95    : i.idleSince + c.settle.minutes * 60_000
96  if (i.now < fireAt) return { action: 'wait', fireAt, trigger: trigger.kind, focus: trigger.focus }
97  return { action: 'fire', trigger: trigger.kind, focus: trigger.focus }
98}
99
hooks/lib/time-parser.js 214 lines
1// Optional calendar date ahead of the clock — weekly limits render "resets Aug 21 at 3pm
2// (Australia/Brisbane)". Month names are matched by their first three letters so both
3// "Aug" and "August" resolve; the day may carry an ordinal suffix or a trailing comma.
4const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
5const RESET_TIME_REGEX = /resets?\s+(?:on\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
6const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(?:in\s+)?(\d+)\s*(hours?|minutes?|mins?|h|m)\b/i;
7
8export function parseResetTime(text) {
9  // Try absolute time first: "resets at 3pm (UTC)"
10  const absMatch = text.match(RESET_TIME_REGEX);
11  if (absMatch) {
12    // A word before the day that isn't a month ("resets tomorrow 3pm") must not be read as
13    // one: the optional date group only binds when the token names a month.
14    const monthIdx = absMatch[1] ? MONTHS.indexOf(absMatch[1].toLowerCase()) : -1;
15    if (absMatch[1] && monthIdx === -1) return null;
16    let hour = parseInt(absMatch[3], 10);
17    const minute = absMatch[4] ? parseInt(absMatch[4], 10) : 0;
18    const ampm = absMatch[5]?.toLowerCase() || null;
19    const timezone = absMatch[6] || null;
20
21    if (ampm === 'pm' && hour !== 12) hour += 12;
22    if (ampm === 'am' && hour === 12) hour = 0;
23
24    // Reject an out-of-range clock (e.g. a bare "resets 30"): a bad hour/minute would make
25    // calculateWaitMs build an invalid Date and throw, crashing the monitor. null → fallback.
26    if (hour > 23 || hour < 0 || minute > 59) return null;
27
28    const ambiguous = !ampm && hour >= 1 && hour <= 12;
29    if (monthIdx !== -1) {
30      const day = parseInt(absMatch[2], 10);
31      if (day < 1 || day > 31) return null;
32      return { hour, minute, timezone, ambiguous, month: monthIdx, day };
33    }
34    return { hour, minute, timezone, ambiguous };
35  }
36
37  // Try relative time: "try again in 5 minutes" / "wait 2 hours"
38  const relMatch = text.match(RELATIVE_TIME_REGEX);
39  if (relMatch) {
40    const amount = parseInt(relMatch[1], 10);
41    const unit = relMatch[2].toLowerCase();
42    const isMinutes = unit.startsWith('m');
43    const ms = amount * (isMinutes ? 60_000 : 3_600_000);
44    return { relative: true, waitMs: ms };
45  }
46
47  return null;
48}
49
50// Reset-boundary grace window. A live limit banner whose parsed reset time is already in
51// the PAST almost always means the reset just happened: the monitor can settle on the
52// banner minutes-to-an-hour after the reset (a session that kept working past it — see the
53// chrome-aware anti-spam guard), and Claude's session limits reset on short cadences, so a
54// past reset time is recent, not "tomorrow". Rolling a just-passed reset a full day forward
55// parks the session ~24h even though the limit has effectively cleared (observed live:
56// "resets 10am" detected at 10:03 → 86273s wait). rollPastReset retries promptly instead
57// (diff→0, so the wait is just the margin); only a reset MORE than the grace window in the
58// past plausibly means the next occurrence is tomorrow — and "tomorrow" must be computed
59// date-anchored (getTargetTimestamp with dayOffset 1), NOT as a flat +24h of milliseconds:
60// across a DST fall-back transition tomorrow's wall-clock time is 25h away, so +24h woke
61// the monitor an hour EARLY with the banner still live (burning maxRetries into a limited
62// session, then giving up before the real reset); spring-forward over-waited an hour.
63const RESET_GRACE_MS = 60 * 60 * 1000; // 1 hour
64
65export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, now = new Date()) {
66  if (!parsed) return (fallbackHours * 3600 + marginSeconds) * 1000;
67
68  // Handle relative times: "try again in 5 minutes"
69  if (parsed.relative) {
70    return parsed.waitMs + marginSeconds * 1000;
71  }
72
73  let tz;
74  try {
75    tz = parsed.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
76    // Validate timezone early to avoid cryptic errors later
77    Intl.DateTimeFormat('en-US', { timeZone: tz });
78  } catch {
79    // Invalid timezone (possibly garbled by TUI capture) — use fallback
80    return (fallbackHours * 3600 + marginSeconds) * 1000;
81  }
82
83  // DST-safe approach: binary search for the correct UTC timestamp that corresponds to
84  // the given hour:minute in the target timezone, on today's date there (dayOffset 0) or
85  // a following day (dayOffset 1 = the roll-to-tomorrow path — anchored to the actual
86  // calendar day so a 23h/25h DST day converges to the right instant).
87  // Today's calendar date in the target timezone.
88  function todayInTz() {
89    const parts = new Intl.DateTimeFormat('en-US', {
90      timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
91      hour12: false,
92    }).formatToParts(now);
93    return {
94      y: parseInt(parts.find(p => p.type === 'year').value),
95      mo: parseInt(parts.find(p => p.type === 'month').value) - 1,
96      d: parseInt(parts.find(p => p.type === 'day').value),
97    };
98  }
99  // `date` ({y, mo, d}) anchors the target to an explicit calendar day (a dated weekly
100  // reset); otherwise today in tz, plus dayOffset (the roll-to-tomorrow path).
101  function getTargetTimestamp(h, m, dayOffset = 0, date = null) {
102    let { y, mo, d } = date || todayInTz();
103    if (dayOffset) {
104      // Normalize month/year rollover through Date.UTC (calendar-day arithmetic only).
105      const norm = new Date(Date.UTC(y, mo, d + dayOffset));
106      y = norm.getUTCFullYear(); mo = norm.getUTCMonth(); d = norm.getUTCDate();
107    }
108
109    // Construct target date string and parse in HOST-local time as the initial guess
110    // (a UTC anchor put the guess up to a full offset away; host-local is usually close).
111    const targetStr = `${y}-${String(mo + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
112    const guess = new Date(targetStr);
113
114    // Iterative correction: render the guess in the target TZ and move by the FULL
115    // wall-clock delta — date included — between desired (today@h:m in tz) and rendered.
116    // Anchoring to the date avoids any ±12h minimum-magnitude heuristic, which picked
117    // the wrong day whenever the guess landed >12h away in wall-clock terms (banner tz
118    // beyond UTC±12 like Pacific/Auckland in summer, or a host/banner offset split >12h)
119    // — the off-by-a-day bug. Up to 3 passes for DST convergence.
120    // hourCycle h23 (not hour12:false): ICU's h24 quirk can render midnight as "24:xx"
121    // paired with the previous day's date, which would skew the date-anchored delta.
122    const fmt = new Intl.DateTimeFormat('en-US', {
123      timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
124      hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
125    });
126    // The candidate's wall-clock rendering in tz, as a comparable UTC-ms scalar.
127    const rendered = (ts) => {
128      const fp = fmt.formatToParts(new Date(ts));
129      const get = t => parseInt(fp.find(p => p.type === t).value);
130      return Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'));
131    };
132    const targetWall = Date.UTC(y, mo, d, h, m);
133
134    let candidate = guess.getTime();
135    let prev = candidate;
136    for (let i = 0; i < 3; i++) {
137      const diffMin = (targetWall - rendered(candidate)) / 60_000;
138      if (diffMin === 0) break;
139      prev = candidate;
140      candidate += diffMin * 60_000;
141    }
142
143    // DST transition-day resolution — deterministic and host-independent (the loop's
144    // outcome otherwise depends on which side the HOST-local initial guess approached
145    // from). Both resolve to the LATE side: waking an hour late is safe, waking early
146    // finds the banner still live and burns maxRetries.
147    if (rendered(candidate) !== targetWall) {
148      // Nonexistent wall time (spring-forward gap): the loop oscillates between the
149      // instants just before and just after the jump — take the later one (the first
150      // real instant at/after the intended time).
151      candidate = Math.max(candidate, prev);
152    } else if (rendered(candidate + 3600_000) === targetWall) {
153      // Repeated wall time (fall-back): converged on the earlier occurrence — move to
154      // the later one.
155      candidate += 3600_000;
156    }
157
158    return candidate;
159  }
160
161  // A calendar-dated reset (weekly limit) is authoritative: anchor to that day, never to
162  // "today or tomorrow". The year is inferred — the banner's date is the nearest one at or
163  // after yesterday in tz, so a "Jan 2" seen on Dec 30 is next year's. Past means the limit
164  // already cleared: retry now (margin only), never roll a whole year forward.
165  if (parsed.month !== undefined && parsed.day !== undefined) {
166    const today = todayInTz();
167    const sameYear = Date.UTC(today.y, parsed.month, parsed.day);
168    const yesterday = Date.UTC(today.y, today.mo, today.d - 1);
169    const y = sameYear >= yesterday ? today.y : today.y + 1;
170    const date = { y, mo: parsed.month, d: parsed.day };
171    const at = (h) => getTargetTimestamp(h, parsed.minute, 0, date) - now.getTime();
172    let target = at(parsed.hour);
173    if (parsed.ambiguous) {
174      const alt = at((parsed.hour + 12) % 24);
175      const future = [target, alt].filter((x) => x > 0);
176      target = future.length ? Math.min(...future) : Math.max(target, alt);
177    }
178    return Math.max(0, target) + marginSeconds * 1000;
179  }
180
181  if (parsed.ambiguous) {
182    const t1 = getTargetTimestamp(parsed.hour, parsed.minute);
183    const t2 = getTargetTimestamp((parsed.hour + 12) % 24, parsed.minute);  // %24: 12→0 (midnight), never hour 24 (→ Invalid Date)
184    const d1 = t1 - now.getTime();
185    const d2 = t2 - now.getTime();
186
187    let target;
188    if (d1 > 0 && d2 > 0) target = Math.min(d1, d2);
189    else if (d1 > 0) target = d1;
190    else if (d2 > 0) target = d2;
191    else {
192      // Both interpretations are past. Grace-check the MOST RECENT one (is it just-passed?);
193      // but if we roll to tomorrow, roll to the EARLIEST occurrence, not the later pm one —
194      // otherwise we wait ~12h longer than necessary. Recompute tomorrow's instant
195      // date-anchored (dayOffset 1) rather than adding flat 24h, which is ±1h across DST.
196      const recent = Math.max(d1, d2);
197      const earlyHour = d1 <= d2 ? parsed.hour : (parsed.hour + 12) % 24;
198      target = recent > -RESET_GRACE_MS ? 0
199        : getTargetTimestamp(earlyHour, parsed.minute, 1) - now.getTime();
200    }
201
202    return Math.max(0, target) + marginSeconds * 1000;
203  }
204
205  // Roll a stale (past-grace) reset to TOMORROW's occurrence, date-anchored (see the
206  // RESET_GRACE_MS comment for both the grace rationale and why not a flat +24h).
207  const today = getTargetTimestamp(parsed.hour, parsed.minute) - now.getTime();
208  const diff = today >= 0 ? today
209    : today > -RESET_GRACE_MS ? 0
210    : getTargetTimestamp(parsed.hour, parsed.minute, 1) - now.getTime();
211
212  return diff + marginSeconds * 1000;
213}
214
hooks/view.ts 97 lines
1import type { Config } from './config'
2import type { KeepGoingCore, KeepGoingView } from '../types'
3
4export function clockOf(ms: number): string {
5  const d = new Date(ms)
6  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
7}
8
9export function durationOf(ms: number): string {
10  const s = Math.max(0, Math.round(ms / 1000))
11  if (s < 90) return `${s}s`
12  const m = Math.round(s / 60)
13  if (m < 90) return `${m}m`
14  const h = Math.floor(m / 60)
15  return `${h}h${String(m % 60).padStart(2, '0')}m`
16}
17
18const RETRY_LABEL = {
19  overload: 'API error',
20  safeguard: 'Safeguard flag',
21  interrupted: 'Response cut off',
22} as const
23
24/**
25 * What the status line and the band show: one line for the most pressing
26 * thing pending, null when there is nothing to say.
27 */
28export function viewOf(core: KeepGoingCore, config: Config, now: number): KeepGoingView {
29  if (core.isPaused) return { line: 'paused for this session', detail: null, actions: [] }
30
31  const u = core.usage
32  if (u !== null) {
33    if (u.isGivenUp) {
34      return { line: `gave up after ${u.attempts} continues`, detail: u.banner, actions: ['continue', 'cancel'] }
35    }
36    if (now < u.until) {
37      const line = u.attempts === 0
38        ? `usage limit, continuing at ${clockOf(u.until)} (in ${durationOf(u.until - now)})`
39        : `continue ${u.attempts}/${config.maxRetries} sent, checking again at ${clockOf(u.until)}`
40      return { line, detail: u.banner, actions: ['continue', 'cancel'] }
41    }
42    return { line: 'usage limit reset, continuing', detail: u.banner, actions: ['continue', 'cancel'] }
43  }
44
45  const f = core.fallback
46  if (f !== null && f.phase === 'active') {
47    return { line: `${f.from} limit, on ${f.to} until ${clockOf(f.resetAt)}`, detail: null, actions: [] }
48  }
49
50  const r = core.retry
51  if (r !== null) {
52    const label = RETRY_LABEL[r.family]
53    if (r.isGivenUp) return { line: `${label}: gave up after ${r.attempts} retries`, detail: r.detail, actions: ['continue', 'cancel'] }
54    if (r.isAwaitingResult) return { line: `${label}: retry ${r.attempts} sent`, detail: r.detail, actions: ['cancel'] }
55    return {
56      line: `${label}: retry ${r.attempts + 1} in ${durationOf(r.until - now)}`,
57      detail: r.detail,
58      actions: ['continue', 'cancel'],
59    }
60  }
61
62  const c = core.compact
63  if (c.scheduledFor > now && c.scheduledFor !== 0) {
64    return { line: `compacting at ${clockOf(c.scheduledFor)}`, detail: null, actions: ['compact', 'skip'] }
65  }
66
67  return { line: config.ui.statusLine === 'always' ? 'watching' : null, detail: null, actions: [] }
68}
69
70/**
71 * The short badge a statusline script can show beside its own fields, as
72 * claude-keep-going's tmux and statusline badge did: 🟢KG while watching,
73 * a countdown while something is pending.
74 */
75export function badgeOf(core: KeepGoingCore, now: number): string {
76  if (core.isPaused) return '⏸KG'
77  const u = core.usage
78  if (u !== null) {
79    if (u.isGivenUp) return '🔴KG'
80    const left = Math.max(0, u.until - now)
81    const minutes = Math.floor(left / 60_000)
82    return minutes >= 60
83      ? `⏳KG ${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
84      : `⏳KG ${minutes}m`
85  }
86  if (core.fallback !== null && core.fallback.phase !== 'switch') return '🔀KG'
87  const r = core.retry
88  if (r !== null) {
89    if (r.isGivenUp) return '🔴KG'
90    const icon = r.family === 'overload' ? '🟠' : r.family === 'safeguard' ? '🛡' : '🔁'
91    return r.isAwaitingResult ? `${icon}KG` : `${icon}KG ${Math.max(0, Math.round((r.until - now) / 1000))}s`
92  }
93  const at = core.compact.scheduledFor
94  if (at > now) return `🟢KG 🗜${Math.ceil((at - now) / 60_000)}m`
95  return '🟢KG'
96}
97
types/index.d.ts 102 lines
1export type KeepGoingRetryFamily = 'overload' | 'safeguard' | 'interrupted'
2
3/**
4 * What Claude Code's own auto-continue said about this limit: armed for the
5 * reset, fired, waiting for Enter (stale), or not going to continue.
6 */
7export type KeepGoingNativeNotice = 'armed' | 'fired' | 'stale' | 'disabled'
8
9export type KeepGoingUsageWait = {
10  until: number
11  enteredAt: number
12  isFallback: boolean
13  attempts: number
14  banner: string
15  submittedAt: number
16  nativeNotice: KeepGoingNativeNotice | null
17  nativeNoticeAt: number
18  hasLoggedGrace: boolean
19  hasLoggedMissed: boolean
20  networkDownSince: number
21  isGivenUp: boolean
22}
23
24export type KeepGoingRetry = {
25  family: KeepGoingRetryFamily
26  attempts: number
27  until: number
28  startedAt: number
29  totalWaitMs: number
30  lastSentAt: number
31  isAwaitingResult: boolean
32  isGivenUp: boolean
33  detail: string
34}
35
36export type KeepGoingFallback = {
37  from: string
38  to: string
39  original: string
40  resetAt: number
41  phase: 'switch' | 'continue' | 'active' | 'restore'
42  banner: string
43}
44
45export type KeepGoingCompact = {
46  request: { at: number; focus: string } | null
47  lastSentAt: number
48  handledIdleSince: number
49  lastMatchedAnswer: string | null
50  scheduledFor: number
51  reportedIdleSince: number
52}
53
54export type KeepGoingActivity = {
55  isBusy: boolean
56  turnStartedAt: number
57  idleSince: number
58  lastUserAt: number
59  background: number | null
60  crons: number
61  permissionAt: number
62  lastAnswer: string
63  failureTurnStartedAt: number
64  /** The model the session was on when it went idle; another one now means a cold cache. */
65  idleModel: string
66  lastEditAt: number
67}
68
69/** Scheduled work the session is waiting on, from the tool calls that set it up. */
70export type KeepGoingScheduled = {
71  recurringCrons: number
72  oneShotCrons: number
73  wakeUntil: number
74  workflows: number
75}
76
77export type KeepGoingCore = {
78  usage: KeepGoingUsageWait | null
79  retry: KeepGoingRetry | null
80  fallback: KeepGoingFallback | null
81  wrapUp: { noticeAt: number; dueAt: number; nudges: number }
82  compact: KeepGoingCompact
83  activity: KeepGoingActivity
84  scheduled: KeepGoingScheduled
85  /** Full model ids seen answering, by family (opus, sonnet, ...). */
86  models: Record<string, string>
87  isPaused: boolean
88  cacheTtlMs: number | null
89}
90
91export type KeepGoingView = {
92  line: string | null
93  detail: string | null
94  actions: readonly ('continue' | 'cancel' | 'compact' | 'skip')[]
95}
96
97declare module 'claude-code' {
98  interface PluginState {
99    'keep-going': { core: KeepGoingCore | null; view: KeepGoingView | null }
100  }
101}
102