Retries a turn that died on a transient API error (500, 529 overloaded, timeouts, connection resets) with backoff, and resumes work when the usage limit resets.

A Claude Code mod that retries a turn that died on a transient API error and resumes the work when your usage limit resets, so a long session does not wait for someone to type "continue".
keep-going: retry 2/4 in 0:45 [ cancel ]
keep-going: resuming at reset in 1h 12m [ cancel ]
keep-going: not retrying: authentication failed.
Long sessions die on errors that would have passed a minute later, and the work then sits until a person comes back:
Claude Code already retries a single request several times. keep-going works one level up: when the whole turn has failed, it waits and asks the model to continue from where it stopped.
| Turn ended with | keep-going does |
|---|---|
5xx, 529 overloaded, a timeout, stream idle timeout, connection reset (ECONN*), "tool call could not be parsed" | Retries after 30s, 60s, 120s, 240s, at most maxRetries in a row |
| Usage limit, with a used-up 5h or 7d window | Resumes one minute after that window resets |
| 429 with no used-up window (a short request rate limit) | Retries with the same backoff |
| Auth failure, billing, 400 invalid request, context too long, an error it cannot name | Nothing; a toast says why |
| You pressed Esc, or the model refused | Nothing |
The retry prompt is: "The previous request failed with a transient API error (overloaded (529)). Continue the task from where you stopped; do not repeat finished steps." The resume prompt says the same about the limit reset.
While a retry or resume is pending:
cancel button (or the status line shows the countdown, see display)./keep-going itself does not./keep-going prints the state and this session's history. /keep-going off, on and cancel do what they say.
You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
From the marketplace in this repo:
/plugin marketplace add Jvrd97/claude-keep-going
/plugin install keep-going@keep-going
Or straight from disk, for one session:
git clone https://github.com/Jvrd97/claude-keep-going.git
claude --plugin-dir ./claude-keep-going
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
maxRetries | 4 | Retries in a row after transient errors. 0 turns retries off |
maxPerSession | 20 | Hard cap on retries and resumes sent in one session |
resumeAfterLimit | true | Resume one minute after a used-up window resets |
display | band | band draws the countdown with a cancel button above the prompt; status writes it to the status line |
classic.StopFailure gives the error kind the engine assigned (server_error, rate_limit, authentication_failed, ...). session.append gives the text of the error row the engine writes in the model's place ("API Error: 529 ..."); only rows stamped with the engine's <synthetic> model are read, never the model's own words. The two can arrive in either order, so the verdict waits up to 2 seconds for them, and falls back to the turn's final text.turn.complete with reason: 'error' on the main loop starts the decision. Subagent turns, aborted and refusal are ignored.$.prompt.submit. The model sees it as a message from the keep-going plugin, and the transcript names the plugin.$.clock.after holds the timer. When it fires while another turn is running, it checks again every 5 seconds and sends only once the session is idle.prompt.submit from the composer, the Remote Control bridge or the SDK host counts as you typing. Prompts from plugins, notifications and peers do not.$.session.usage(): the latest resetsAt among windows at 100%, plus 60 seconds.$.state. The mod writes no files and makes no network calls.What it cannot do:
/clear and the end of the session cancel a pending retry: the engine drops the module's timers with the old module, and the history records it. A claude -p run ends with its turn, so it never waits for a retry.Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:
tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Мод повторяет ход, который упал на временной ошибке API (500, 529 overloaded, таймаут, обрыв соединения), чтобы никому не приходилось писать «continue».
maxRetries подряд и maxPerSession за сессию./keep-going показывает состояние и историю, /keep-going off|on|cancel управляет модом. Мод ничего не отправляет в сеть и не пишет файлы.
MIT
hooks/register.tsx 418 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register, Timer, TurnCompleteReason } from 'claude-code'
3
4import type { Pending } from '../types'
5import {
6 addHistory,
7 backoffMs,
8 classify,
9 countdown,
10 engineRowText,
11 isOwnCommand,
12 isPerson,
13 parseCommand,
14 parseSettings,
15 pendingLine,
16 report,
17 resumeAt,
18 retryPrompt,
19 RESUME_PROMPT,
20 tickDelay,
21} from './logic'
22import type { Settings, Verdict } from './logic'
23
24const COMMAND = 'keep-going'
25/** How long to wait for the StopFailure details when a turn ends on an error with no text to classify. */
26const DETAILS_GRACE_MS = 2_000
27/** When a retry falls due while another turn runs, check again after this long. */
28const RECHECK_MS = 5_000
29const NOTICE_TOAST_MS = 8_000
30
31const pendingAtom = atom({ plugin: 'keep-going', key: 'pending' } as const, null)
32const streakAtom = atom({ plugin: 'keep-going', key: 'streak' } as const, 0)
33const totalAtom = atom({ plugin: 'keep-going', key: 'total' } as const, 0)
34const isEnabledAtom = atom({ plugin: 'keep-going', key: 'isEnabled' } as const, true)
35const historyAtom = atom({ plugin: 'keep-going', key: 'history' } as const, [])
36const tickAtom = atom({ plugin: 'keep-going', key: 'tick' } as const, 0)
37
38/**
39 * What lives only as long as this load of the module. Timers die with it on a hot reload (the engine
40 * cancels them), which is why session.start drops a pending retry it finds in state.
41 */
42type Runtime = {
43 settings: Settings
44 timer: Timer | null
45 ticker: Timer | null
46 grace: Timer | null
47 isTurnRunning: boolean
48 /** The main-loop turn that ended on an error and still waits for a verdict. */
49 failedTurn: string | null
50 stopFailure: { kind: string; details: string } | null
51 /** Text of the engine's own stand-in assistant row this turn: where its API error message lands. */
52 errorText: string
53 /** The failed turn's final visible text: a weak fallback when nothing else says what went wrong. */
54 answer: string
55}
56
57function stopTimers(rt: Runtime): void {
58 rt.timer?.cancel()
59 rt.ticker?.cancel()
60 rt.timer = null
61 rt.ticker = null
62}
63
64async function note($: EngineInterface, text: string): Promise<void> {
65 const at = await $.clock.now()
66 await update($, historyAtom, list => addHistory(list, { at, text }))
67}
68
69async function clearPending($: EngineInterface, rt: Runtime): Promise<Pending | null> {
70 stopTimers(rt)
71 const was = await read($, pendingAtom)
72 await update($, pendingAtom, () => null)
73
74 if (rt.settings.display === 'status') {
75 $.ui.status(undefined)
76 }
77
78 return was
79}
80
81async function cancelPending($: EngineInterface, rt: Runtime, why: string): Promise<void> {
82 const was = await clearPending($, rt)
83
84 if (was !== null) {
85 await note($, `${was.kind} cancelled: ${why}`)
86 }
87}
88
89async function showTick($: EngineInterface, rt: Runtime): Promise<void> {
90 rt.ticker = null
91 const pending = await read($, pendingAtom)
92
93 if (pending === null) {
94 return
95 }
96
97 const now = await $.clock.now()
98 await update($, tickAtom, n => n + 1)
99
100 if (rt.settings.display === 'status') {
101 $.ui.status(pendingLine(pending, now))
102 }
103
104 rt.ticker = $.clock.after(tickDelay(pending.dueAt - now), () => {
105 void showTick($, rt)
106 })
107}
108
109async function schedule($: EngineInterface, rt: Runtime, pending: Pending): Promise<void> {
110 stopTimers(rt)
111 const now = await $.clock.now()
112 await update($, pendingAtom, () => pending)
113 rt.timer = $.clock.after(Math.max(0, pending.dueAt - now), () => {
114 void fire($, rt)
115 })
116 await showTick($, rt)
117}
118
119async function fire($: EngineInterface, rt: Runtime): Promise<void> {
120 rt.timer = null
121 const pending = await read($, pendingAtom)
122
123 if (pending === null) {
124 return
125 }
126
127 if (rt.isTurnRunning) {
128 rt.timer = $.clock.after(RECHECK_MS, () => {
129 void fire($, rt)
130 })
131
132 return
133 }
134
135 await clearPending($, rt)
136 await update($, totalAtom, n => n + 1)
137
138 if (pending.kind === 'retry') {
139 await update($, streakAtom, () => pending.attempt)
140 }
141
142 await note($, pending.kind === 'retry' ? `retry ${pending.attempt}/${pending.maxRetries} sent` : 'resume sent after the limit reset')
143
144 try {
145 const sent = await $.prompt.submit({ text: pending.kind === 'retry' ? retryPrompt(pending.label) : RESUME_PROMPT })
146
147 if (sent.drop !== undefined) {
148 await refuse($, `the ${pending.kind} prompt was dropped: ${sent.drop}`)
149 }
150 } catch (error) {
151 await refuse($, `could not send the ${pending.kind} prompt: ${error instanceof Error ? error.message : String(error)}`)
152 }
153}
154
155async function refuse($: EngineInterface, why: string): Promise<void> {
156 $.ui.toast(`keep-going: ${why}`, { timeoutMs: NOTICE_TOAST_MS })
157 await note($, why)
158}
159
160async function scheduleRetry($: EngineInterface, rt: Runtime, label: string): Promise<void> {
161 const streak = await read($, streakAtom)
162 const { maxRetries } = rt.settings
163
164 if (streak >= maxRetries) {
165 await refuse($, `${label}: gave up after ${streak} retries in a row. Type "continue" to try again.`)
166
167 return
168 }
169
170 const attempt = streak + 1
171 const delay = backoffMs(attempt)
172 const now = await $.clock.now()
173 await schedule($, rt, { kind: 'retry', attempt, maxRetries, dueAt: now + delay, label })
174 await note($, `turn failed: ${label}; retry ${attempt}/${maxRetries} in ${countdown(delay)}`)
175}
176
177async function scheduleResume($: EngineInterface, rt: Runtime, verdict: Verdict): Promise<void> {
178 const now = await $.clock.now()
179 const dueAt = resumeAt((await $.session.usage()).rateLimits, now)
180
181 if (dueAt === null) {
182 // No window is used up: a short request rate limit, which a plain backoff handles.
183 await scheduleRetry($, rt, 'rate limited')
184
185 return
186 }
187
188 if (!rt.settings.resumeAfterLimit) {
189 await refuse($, `${verdict.label}; resume after the reset is off (resumeAfterLimit).`)
190
191 return
192 }
193
194 await schedule($, rt, { kind: 'resume', dueAt, label: verdict.label })
195 await note($, `turn stopped: ${verdict.label}; resuming at reset in ${countdown(dueAt - now)}`)
196}
197
198async function decide($: EngineInterface, rt: Runtime): Promise<void> {
199 if (rt.failedTurn === null) {
200 return
201 }
202
203 rt.failedTurn = null
204 rt.grace?.cancel()
205 rt.grace = null
206 const known = [rt.stopFailure?.details ?? '', rt.errorText].filter(text => text !== '').join('\n')
207 const failure = { kind: rt.stopFailure?.kind, text: known === '' && rt.stopFailure === null ? rt.answer : known }
208 rt.stopFailure = null
209 rt.errorText = ''
210 rt.answer = ''
211 const verdict = classify(failure)
212
213 if (!(await read($, isEnabledAtom))) {
214 await note($, `turn failed: ${verdict.label}; keep-going is off`)
215
216 return
217 }
218
219 if (verdict.kind === 'fatal') {
220 await refuse($, `not retrying: ${verdict.label}.`)
221
222 return
223 }
224
225 const total = await read($, totalAtom)
226
227 if (total >= rt.settings.maxPerSession) {
228 await refuse($, `${verdict.label}; not retrying, ${total} retries already sent this session (maxPerSession).`)
229
230 return
231 }
232
233 if (verdict.kind === 'limit') {
234 await scheduleResume($, rt, verdict)
235 } else {
236 await scheduleRetry($, rt, verdict.label)
237 }
238}
239
240async function onTurnEnd($: EngineInterface, rt: Runtime, reason: TurnCompleteReason, turnId: string, answer: string): Promise<void> {
241 rt.isTurnRunning = false
242
243 if (reason === 'answer') {
244 if ((await read($, streakAtom)) > 0) {
245 await update($, streakAtom, () => 0)
246 await note($, 'turn succeeded; retry count reset')
247 }
248
249 await cancelPending($, rt, 'a later turn succeeded')
250
251 return
252 }
253
254 if (reason !== 'error') {
255 // 'aborted' is the person pressing Esc, 'refusal' is the model's answer: neither is retried.
256 return
257 }
258
259 rt.failedTurn = turnId
260 rt.answer = answer
261
262 if (rt.stopFailure !== null || rt.errorText !== '') {
263 await decide($, rt)
264
265 return
266 }
267
268 rt.grace = $.clock.after(DETAILS_GRACE_MS, () => {
269 void decide($, rt)
270 })
271}
272
273async function personTookOver($: EngineInterface, rt: Runtime): Promise<void> {
274 await cancelPending($, rt, 'you sent a prompt')
275 await update($, streakAtom, () => 0)
276}
277
278async function runCommand($: EngineInterface, rt: Runtime, args: string): Promise<string> {
279 const action = parseCommand(args)
280
281 if (action === null) {
282 return 'Usage: /keep-going [on|off|cancel]'
283 }
284
285 if (action === 'on') {
286 await update($, isEnabledAtom, () => true)
287
288 return 'keep-going is on.'
289 }
290
291 if (action === 'off') {
292 await update($, isEnabledAtom, () => false)
293 await cancelPending($, rt, 'turned off')
294
295 return 'keep-going is off: failed turns stay failed until you turn it back on.'
296 }
297
298 if (action === 'cancel') {
299 const was = await read($, pendingAtom)
300 await cancelPending($, rt, '/keep-going cancel')
301
302 return was === null ? 'Nothing was pending.' : `Cancelled the pending ${was.kind}.`
303 }
304
305 const snapshot = {
306 isEnabled: await read($, isEnabledAtom),
307 pending: await read($, pendingAtom),
308 streak: await read($, streakAtom),
309 total: await read($, totalAtom),
310 history: await read($, historyAtom),
311 }
312
313 return report(snapshot, rt.settings, await $.clock.now())
314}
315
316export const register: Register = (on, options) => {
317 const rt: Runtime = {
318 settings: parseSettings(options),
319 timer: null,
320 ticker: null,
321 grace: null,
322 isTurnRunning: false,
323 failedTurn: null,
324 stopFailure: null,
325 errorText: '',
326 answer: '',
327 }
328
329 on('session.start', async ($, e, next) => {
330 await $.command.register({ name: COMMAND, description: 'Show retry state and history; /keep-going on|off|cancel' })
331 // A pending retry in state with no timer behind it is left over from before a hot reload.
332 await cancelPending($, rt, 'the module reloaded')
333
334 return next(e)
335 })
336
337 on('session.end', async ($, e, next) => {
338 await cancelPending($, rt, 'the session ended')
339
340 return next(e)
341 })
342
343 on('turn.start', async ($, e, next) => {
344 rt.isTurnRunning = true
345 rt.stopFailure = null
346 rt.errorText = ''
347
348 return next(e)
349 })
350
351 on('session.append', async ($, e, next) => {
352 const text = e.agentId === undefined ? engineRowText(e.message.type, e.origin, e.message.content) : null
353
354 if (text !== null) {
355 rt.errorText = text
356 }
357
358 return next(e)
359 })
360
361 on('classic.StopFailure', async ($, e, next) => {
362 if (e.agent_id === undefined) {
363 rt.stopFailure = { kind: e.error, details: e.error_details ?? '' }
364 await decide($, rt)
365 }
366
367 return next(e)
368 })
369
370 on('turn.complete', async ($, e, next) => {
371 const result = await next(e)
372
373 if (e.agentId === undefined) {
374 await onTurnEnd($, rt, e.reason, e.turnId, e.answer)
375 }
376
377 return result
378 })
379
380 on('prompt.submit', async ($, e, next) => {
381 // origin is stamped by the engine; a submission without one is not attributable to the person.
382 const origin: PromptOrigin | undefined = e.origin
383
384 if (origin !== undefined && isPerson(origin.kind) && !isOwnCommand(e.text)) {
385 await personTookOver($, rt)
386 }
387
388 return next(e)
389 })
390
391 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, rt, e.args) }))
392
393 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
394 if (rt.settings.display !== 'band' || e.props.hasSurvey) {
395 return next(e)
396 }
397
398 const pending = await read($, pendingAtom)
399
400 if (pending === null) {
401 return next(e)
402 }
403
404 await read($, tickAtom)
405 const now = await $.clock.now()
406 const { Box, Button, Text } = $.ui.resolve(e)
407
408 return (
409 <Box>
410 <Text color="yellow" wrap="truncate">
411 {pendingLine(pending, now)}{' '}
412 </Text>
413 <Button key="cancel" label="cancel" onPress={() => cancelPending($, rt, 'cancel pressed')} />
414 </Box>
415 )
416 })
417}
418hooks/logic.ts 296 lines1import type { HistoryEntry, Pending } from '../types'
2
3const SECOND_MS = 1000
4const MINUTE_MS = 60 * SECOND_MS
5const HOUR_MS = 60 * MINUTE_MS
6const FULL_PERCENT = 100
7
8/** The first retry waits this long; each next one waits twice as long. */
9export const BASE_DELAY_MS = 30 * SECOND_MS
10/** Backoff stops doubling here: 30s, 60s, 120s, 240s, 240s, ... */
11export const MAX_DELAY_MS = 240 * SECOND_MS
12/** A resume waits this long past the window's reset, so the first request lands in the new window. */
13export const RESUME_MARGIN_MS = MINUTE_MS
14/** How many history lines the session keeps. */
15export const MAX_HISTORY = 50
16
17export const DEFAULT_MAX_RETRIES = 4
18export const DEFAULT_MAX_PER_SESSION = 20
19
20export type Display = 'band' | 'status'
21
22export type Settings = {
23 maxRetries: number
24 maxPerSession: number
25 resumeAfterLimit: boolean
26 display: Display
27}
28
29/** What the engine told us about a failed turn: the StopFailure kind (if it arrived) and the error text. */
30export type Failure = { kind: string | undefined; text: string }
31
32/**
33 * transient: retry with backoff. limit: a usage or rate limit; resume at the reset when a window is used up.
34 * fatal: retrying cannot help (auth, a bad request, a context that is too long).
35 */
36export type Verdict =
37 | { kind: 'transient'; label: string }
38 | { kind: 'limit'; label: string }
39 | { kind: 'fatal'; label: string }
40
41const FATAL_KINDS: Record<string, string> = {
42 authentication_failed: 'authentication failed',
43 oauth_org_not_allowed: 'organization not allowed',
44 account_on_hold: 'account on hold',
45 verification_required: 'verification required',
46 billing_error: 'billing error',
47 model_not_found: 'model not found',
48 cloud_credential_error: 'cloud credentials error',
49 max_output_tokens: 'output token limit',
50}
51
52const AUTH_FAILED = /failed to authenticate|invalid api key|run \/login|oauth (session|refresh token) (expired|is no longer valid)/i
53const CONTEXT_TOO_LONG = /prompt is too long|context (window|length) (exceeded|is full)|exceeds? the (maximum )?context|maximum context length/i
54const CREDITS_REQUIRED = /credits? required|out of extra usage|extra usage (is )?(disabled|off)/i
55const LIMIT_TEXT = /usage limit|limit reached|hit your (\w+ )?limit|rate[ _-]?limit|\b429\b/i
56
57/** Transient patterns in the order they are checked, the first match names the error. */
58const TRANSIENT_TEXT: readonly (readonly [RegExp, string])[] = [
59 [/could not be parsed/i, 'tool call could not be parsed'],
60 [/stream idle timeout/i, 'stream idle timeout'],
61 [/\b529\b|overloaded|at capacity/i, 'overloaded (529)'],
62 [/\b(ECONN[A-Z]+|ETIMEDOUT|EPIPE|ENOTFOUND|EAI_AGAIN)\b/, 'connection error'],
63 [/socket hang up|connection (reset|error|refused|closed|was lost)|network error|fetch failed/i, 'connection error'],
64 [/timed? ?out|timeout/i, 'timeout'],
65 [/internal server error|bad gateway|service unavailable|gateway timeout/i, 'server error'],
66]
67
68const API_STATUS = /API Error:?\s*(\d{3})\b/i
69
70const statusOf = (text: string): number | null => {
71 const match = API_STATUS.exec(text)
72
73 return match?.[1] === undefined ? null : Number(match[1])
74}
75
76const transientLabel = (text: string): string | null => {
77 for (const [pattern, label] of TRANSIENT_TEXT) {
78 const match = pattern.exec(text)
79
80 if (match !== null) {
81 return label === 'connection error' && /^E[A-Z]+$/.test(match[1] ?? '') ? `connection error (${match[1]})` : label
82 }
83 }
84
85 return null
86}
87
88const HTTP_SERVER_ERROR = 500
89const HTTP_CLIENT_ERROR = 400
90
91/** Sorts a failed turn into retry, resume-at-reset or give-up. */
92export const classify = (failure: Failure): Verdict => {
93 const { kind, text } = failure
94 const fatalKind = kind === undefined ? undefined : FATAL_KINDS[kind]
95
96 if (fatalKind !== undefined) {
97 return { kind: 'fatal', label: fatalKind }
98 }
99
100 if (AUTH_FAILED.test(text)) {
101 return { kind: 'fatal', label: 'authentication failed' }
102 }
103
104 if (CONTEXT_TOO_LONG.test(text)) {
105 return { kind: 'fatal', label: 'context too long' }
106 }
107
108 if (kind === 'rate_limit' || LIMIT_TEXT.test(text)) {
109 return CREDITS_REQUIRED.test(text)
110 ? { kind: 'fatal', label: 'usage credits required' }
111 : { kind: 'limit', label: 'usage limit reached' }
112 }
113
114 const named = transientLabel(text)
115
116 if (named !== null) {
117 return { kind: 'transient', label: named }
118 }
119
120 const status = statusOf(text)
121
122 if (status !== null && status >= HTTP_SERVER_ERROR) {
123 return { kind: 'transient', label: `server error (${status})` }
124 }
125
126 if (kind === 'overloaded') {
127 return { kind: 'transient', label: 'overloaded (529)' }
128 }
129
130 if (kind === 'server_error') {
131 return { kind: 'transient', label: 'server error' }
132 }
133
134 if (kind === 'invalid_request' || (status !== null && status >= HTTP_CLIENT_ERROR)) {
135 return { kind: 'fatal', label: status === null ? 'invalid request' : `invalid request (${status})` }
136 }
137
138 return { kind: 'fatal', label: kind === undefined || kind === 'unknown' ? 'unrecognised error' : kind }
139}
140
141/** Delay before retry number `attempt` (1-based). */
142export const backoffMs = (attempt: number): number =>
143 Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1))
144
145type Window = { kind: string; percentUsed: number; resetsAt?: string }
146
147/**
148 * When to resume after a limit: the latest reset among the windows that are used up, plus the margin.
149 * Null when no window is used up (a short API rate limit, which a plain retry handles) or no reset is known.
150 */
151export const resumeAt = (windows: readonly Window[], now: number): number | null => {
152 const resets = windows
153 .filter(window => window.percentUsed >= FULL_PERCENT && window.resetsAt !== undefined)
154 .map(window => Date.parse(window.resetsAt ?? ''))
155 .filter(at => !Number.isNaN(at) && at > now)
156
157 return resets.length === 0 ? null : Math.max(...resets) + RESUME_MARGIN_MS
158}
159
160const pad = (n: number): string => String(n).padStart(2, '0')
161
162/** "0:45" under an hour, "1h 12m" above. */
163export const countdown = (ms: number): string => {
164 const clamped = Math.max(0, ms)
165
166 if (clamped < HOUR_MS) {
167 const seconds = Math.ceil(clamped / SECOND_MS)
168
169 return `${Math.floor(seconds / 60)}:${pad(seconds % 60)}`
170 }
171
172 const minutes = Math.round(clamped / MINUTE_MS)
173
174 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
175}
176
177export const ago = (ms: number): string => {
178 if (ms < MINUTE_MS) {
179 return 'just now'
180 }
181
182 return ms < HOUR_MS ? `${Math.round(ms / MINUTE_MS)}m ago` : `${countdown(ms)} ago`
183}
184
185/** What the band and the status line say about a pending prompt. */
186export const pendingLine = (pending: Pending, now: number): string =>
187 pending.kind === 'retry'
188 ? `keep-going: retry ${pending.attempt}/${pending.maxRetries} in ${countdown(pending.dueAt - now)}`
189 : `keep-going: resuming at reset in ${countdown(pending.dueAt - now)}`
190
191/** How often the countdown redraws: every second near the deadline, every minute when it is hours away. */
192export const tickDelay = (remainingMs: number): number => (remainingMs > HOUR_MS ? MINUTE_MS : SECOND_MS)
193
194export const retryPrompt = (label: string): string =>
195 `The previous request failed with a transient API error (${label}). Continue the task from where you stopped; do not repeat finished steps.`
196
197export const RESUME_PROMPT =
198 'The usage limit has reset. Continue the task from where you stopped; do not repeat finished steps.'
199
200/** The prompt origins that are the person at the keyboard (or the SDK host acting for them). */
201const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk', 'slack-ping'])
202
203export const isPerson = (originKind: string): boolean => PERSON_ORIGINS.has(originKind)
204
205const COMMAND_PATTERN = /^\/keep-going(\s|$)/
206
207/** True for `/keep-going ...` itself, which must not count as "the person took over". */
208export const isOwnCommand = (text: string): boolean => COMMAND_PATTERN.test(text.trim())
209
210export type CommandAction = 'status' | 'on' | 'off' | 'cancel'
211
212export const parseCommand = (args: string): CommandAction | null => {
213 const word = args.trim().toLowerCase()
214
215 if (word === '' || word === 'status') {
216 return 'status'
217 }
218
219 return word === 'on' || word === 'off' || word === 'cancel' ? word : null
220}
221
222const wholeAtLeast = (value: unknown, fallback: number, min: number): number => {
223 const n = Number(value)
224
225 return Number.isFinite(n) ? Math.max(min, Math.floor(n)) : fallback
226}
227
228export const parseSettings = (options: Readonly<Record<string, unknown>>): Settings => ({
229 maxRetries: wholeAtLeast(options.maxRetries ?? DEFAULT_MAX_RETRIES, DEFAULT_MAX_RETRIES, 0),
230 maxPerSession: wholeAtLeast(options.maxPerSession ?? DEFAULT_MAX_PER_SESSION, DEFAULT_MAX_PER_SESSION, 0),
231 resumeAfterLimit: options.resumeAfterLimit !== false,
232 display: options.display === 'status' ? 'status' : 'band',
233})
234
235export const addHistory = (list: readonly HistoryEntry[], entry: HistoryEntry): HistoryEntry[] =>
236 [...list, entry].slice(-MAX_HISTORY)
237
238type Block = { type: string; text?: unknown }
239
240/** The visible text of a row's blocks. */
241export const textOfBlocks = (blocks: readonly Block[]): string =>
242 blocks
243 .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
244 .filter(text => text !== '')
245 .join('\n')
246
247/** The model id the engine stamps on assistant rows it writes itself, API error messages among them. */
248export const SYNTHETIC_MODEL = '<synthetic>'
249
250/**
251 * The text of an assistant row the engine wrote in the model's place (where "API Error: 529 ..." lands),
252 * or null for anything else: a real model's words are never read as an error.
253 */
254export const engineRowText = (type: string, origin: { kind: string; model?: string }, blocks: readonly Block[]): string | null => {
255 if (type !== 'assistant' || origin.kind !== 'model' || origin.model !== SYNTHETIC_MODEL) {
256 return null
257 }
258
259 const text = textOfBlocks(blocks)
260
261 return text === '' ? null : text
262}
263
264export type Snapshot = {
265 isEnabled: boolean
266 pending: Pending | null
267 streak: number
268 total: number
269 history: readonly HistoryEntry[]
270}
271
272/** What `/keep-going` prints. */
273export const report = (snapshot: Snapshot, settings: Settings, now: number): string => {
274 const lines = [
275 `Status: ${snapshot.isEnabled ? 'on' : 'off'} · retries in a row ${snapshot.streak}/${settings.maxRetries} · sent this session ${snapshot.total}/${settings.maxPerSession} · resume after limit ${settings.resumeAfterLimit ? 'on' : 'off'}`,
276 ]
277
278 lines.push(
279 snapshot.pending === null
280 ? 'Nothing pending.'
281 : `Pending: ${pendingLine(snapshot.pending, now).replace(/^keep-going: /, '')} (${snapshot.pending.label}). /keep-going cancel stops it.`,
282 )
283
284 if (snapshot.history.length === 0) {
285 lines.push('No API errors this session.')
286 } else {
287 lines.push('History:')
288
289 for (const entry of snapshot.history) {
290 lines.push(` ${ago(now - entry.at)}: ${entry.text}`)
291 }
292 }
293
294 return lines.join('\n')
295}
296types/index.d.ts 22 lines1/** A prompt keep-going will send when its timer fires. */
2export type Pending =
3 | { kind: 'retry'; attempt: number; maxRetries: number; dueAt: number; label: string }
4 | { kind: 'resume'; dueAt: number; label: string }
5
6export type HistoryEntry = { at: number; text: string }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'keep-going': {
11 pending: Pending | null
12 /** Retries sent since the last successful turn or the last prompt you typed. */
13 streak: number
14 /** Retries and resumes sent this session. */
15 total: number
16 isEnabled: boolean
17 history: HistoryEntry[]
18 tick: number
19 }
20 }
21}
22