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

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.
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.
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:
request_compact tool, or you did with /keep-going compact, and compact.trigger is request or bothcompact.minContextPercent and compact.minContextTokens, and compact.trigger is policy or both/compact, with compact.matchLastMessageIt 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.
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-going | what is pending |
/keep-going continue | send the pending continue now |
/keep-going cancel | drop the pending continue or compaction |
/keep-going compact [focus] | compact once idle, keeping focus |
/keep-going pause, resume | stop or restart acting in this session |
/keep-going log | the last 20 log lines |
A wait survives a restart: claude --continue picks it up again.
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 }
}
--safe-mode, disableAllHooks, or an organization that allows managed mods only.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.
MIT
hooks/register.tsx 415 lines1import { 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}
415hooks/backoff.ts 27 lines1import 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
27hooks/config.ts 270 lines1export 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}
270hooks/keeper.ts 821 lines1import 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}
821hooks/classify.ts 75 lines1export 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}
75hooks/compact.ts 99 lines1import 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}
99hooks/lib/time-parser.js 214 lines1// 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}
214hooks/view.ts 97 lines1import 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}
97types/index.d.ts 102 lines1export 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