Sends one continue prompt after a turn an API error killed, so the half-done work carries on instead of the session going idle, at most 99 times in a row by…

You leave the model working, and when you come back the session sits idle with API Error: Connection lost mid-response and the work half done. The engine gave up after its retries and nobody told the model to go on. This mod does: when an API error kills a turn, it sends one continue prompt so the model carries on, at most 99 times in a row, or as many as you set with /error-poke limit <n>.
turn.complete of the main loop. A subagent's turn is left alone.reason: "error", a turn the engine reports as dead on an API error (retries exhausted, the context limit). A turn you interrupted is aborted and a model refusal is refusal; neither gets a prompt.The previous turn was cut off by an API error, not by me. Continue where you stopped; do not start over. If you cannot tell how far you got, say so and stop.
The interrupted turn's work is still in the transcript, so the prompt asks the model to carry on rather than repeat it.
/error-poke off, cancels the waiting prompt.A turn that a usage limit stopped waits for the limit instead. When a limit reads 100% or more, or the last assistant text is Claude Code's own You've hit your ... limit, the one continue prompt goes out a minute after that limit resets (the latest reset, when several limits are full), because every prompt before that would fail the same way:
error-poke: the turn hit the 5h usage limit, continuing at 14:01 (in 2 h 1 min) (1/99)
Every prompt also writes one line to the transcript, so you know why the session will move by itself:
error-poke: the turn died on an API error, continuing in 5 s (1/99)
/error-poke limit <n> says. At the limit the mod says so once and stops:error-poke: stopped after 99 continue prompts; the API keeps failing. Send a prompt to reset the count.
API error and the usage limit's name are red, the count is faint (yellow once it is within 10% of the limit), and the stop line is red. Without the sidebar, the lines land in the transcript as above./error-poke:send <prompt>, whose body is its arguments alone. The transcript shows that command line, and the model reads the prompt exactly as written, as it reads a typed slash command; a $.prompt.submit text would reach it inside a The error-poke plugin sent a message: frame. When the engine refuses the command, one line says so and the prompt goes out as a plugin prompt, with that frame.What the mod reads is the engine's own reason value, and /error-poke prints how the last turn ended. If a failure you saw got no prompt, that line tells you which value the engine reported.
/error-poke on or off, the count, and how the last turn ended /error-poke on | off on by default /error-poke limit <n> at most n continue prompts in a row; 1 to 999, 99 by default, kept across sessions /error-poke:send <prompt> the command a continue prompt runs; typed, it sends the prompt as written
/error-poke:send is the mod's second command, the one exception to one command per mod, because only a markdown command hands the model a prompt without the plugin frame.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install error-poke@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=error-poke}, prompt.submit, turn.complete ❯ ./register.ts calls: $.clock.after (via afterTurn), $.clock.now (via afterTurn), $.command.register, $.command.run (via sendPoke), $.prompt.submit (via submitPoke), $.session.messages (via readLimitWait), $.session.usage (via readLimitWait), $.sidebar.set (via toPerson), $.store.get (via readLimit, readSettings), $.store.set, $.ui.log (via toPerson)
Reach L2: it drives Claude.
reason. An error the engine reports as something other than error gets no prompt; /error-poke prints the value so you can tell.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 187 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { DEFAULT_MAX_POKES, decide, eventLines, limitLines, limitOf, limitText, limitWait, limitWaitLines, POKE_TEXT, pokeDelay, pokeLines, statusText, type LimitWait, type Line } from './poke.ts'
3
4const ENABLED_KEY = 'enabled'
5const LIMIT_KEY = 'limit'
6
7const USAGE = 'expects nothing (the status), on, off or limit <n>'
8
9/** The mod's markdown command (`commands/send.md`), whose body is its arguments alone. */
10const SEND_COMMAND = 'error-poke:send'
11
12/** The origins of a prompt the person sent themselves, which resets the count. */
13const USER_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
14
15/**
16 * The on/off setting, the limit, the prompts sent since the last prompt of the person, how the last turn
17 * ended, and the continue prompt waiting on its timer, so a prompt of the person or `off` can cancel it.
18 */
19type State = { enabled: boolean; max: number; pokes: number; limitLogged: boolean; lastReason?: string; pending?: Timer }
20
21/**
22 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
23 * transcript line. The model reads nothing of this; it reads the continue prompt itself.
24 */
25async function toPerson($: EngineInterface, key: string, title: string, lines: Line[]): Promise<void> {
26 try {
27 if (await $.sidebar.set({ consumer: 'error-poke', key, title, lines, until: 'stream' })) return
28 } catch {
29 // The sidebar mod is not installed.
30 }
31 $.ui.log(lines.map(l => l.text).join('\n'))
32}
33
34/** The continue prompt as a plugin prompt, which the model reads inside a `The error-poke plugin sent a message:` frame. */
35function submitPoke($: EngineInterface): void {
36 void $.prompt.submit({ text: POKE_TEXT }).then(
37 res => {
38 if (res.drop !== undefined) void toPerson($, 'dropped', 'continue prompt dropped', eventLines('the continue prompt was dropped', res.drop))
39 },
40 (err: unknown) => {
41 void toPerson($, 'failed', 'continue prompt not sent', eventLines('the continue prompt was not submitted', String(err)))
42 },
43 )
44}
45
46/**
47 * Sends the continue prompt through the mod's own `send` command, so the model reads the text alone, as
48 * a typed prompt. It runs from the delay's timer, where the engine takes `$.command.run`; a run the
49 * engine refuses sends the prompt as a plugin prompt instead.
50 */
51function sendPoke($: EngineInterface): void {
52 $.command.run({ command: SEND_COMMAND, args: POKE_TEXT }).catch((err: unknown) => {
53 void toPerson($, 'unsent', 'continue prompt sent as a plugin prompt', eventLines('the send command did not run, the continue prompt goes out as a plugin prompt', String(err)))
54 submitPoke($)
55 })
56}
57
58/**
59 * Whether a usage limit stopped the turn, read from the session's limits and the last assistant text,
60 * because the turn's end names no cause. A read that fails is said once and leaves the backoff in place.
61 */
62async function readLimitWait($: EngineInterface, now: number): Promise<LimitWait | undefined> {
63 try {
64 const limits = (await $.session.usage()).rateLimits
65 const last = (await $.session.messages()).findLast(m => m.role === 'assistant')?.text ?? ''
66 return limitWait(limits, last, now)
67 } catch (err) {
68 await toPerson($, 'limit-read', 'usage limits not read', eventLines('the usage limits were not read, the usual wait applies', String(err)))
69 return undefined
70 }
71}
72
73/** Acts on one main-loop turn that ended: a continue prompt, the limit, or nothing. */
74async function afterTurn($: EngineInterface, state: State, reason: string): Promise<void> {
75 const decision = decide(reason, state.pokes, state.max)
76 if (decision === 'idle') return
77 if (decision === 'limit') {
78 if (state.limitLogged) return
79 state.limitLogged = true
80 await toPerson($, 'limit', 'continue prompts stopped', limitLines(state.max))
81 return
82 }
83 state.pokes += 1
84 const now = await $.clock.now()
85 const wait = await readLimitWait($, now)
86 const lines = wait === undefined ? pokeLines(state.pokes, state.max) : limitWaitLines(wait, now, state.pokes, state.max)
87 await toPerson($, `poke-${state.pokes}`, 'turn continued after an API error', lines)
88 state.pending?.cancel()
89 state.pending = $.clock.after(wait === undefined ? pokeDelay(state.pokes) : wait.until - now, () => {
90 state.pending = undefined
91 void pokeIfOn($, state)
92 })
93}
94
95/** Sends the continue prompt when its timer fires, unless the mod was turned off meanwhile in any window. */
96async function pokeIfOn($: EngineInterface, state: State): Promise<void> {
97 await readSettings($, state)
98 if (state.enabled) sendPoke($)
99}
100
101/** Writes the limit the person set; it holds across sessions, because it lives in $.store. */
102async function setLimit($: EngineInterface, state: State, arg: string): Promise<string> {
103 const limit = limitOf(arg)
104 if (limit === undefined) return limitText(undefined)
105 state.max = limit
106 await $.store.set(LIMIT_KEY, limit)
107 return limitText(limit)
108}
109
110/** The stored limit, or the default when nothing is stored and when the stored value is not one. */
111async function readLimit($: EngineInterface): Promise<number> {
112 const stored = await $.store.get(LIMIT_KEY)
113 return typeof stored === 'number' && limitOf(String(stored)) !== undefined ? stored : DEFAULT_MAX_POKES
114}
115
116/** Starts the count again; a continue prompt still waiting is not sent once the person spoke, or turned the mod off. */
117function resetCount(state: State): void {
118 state.pokes = 0
119 state.limitLogged = false
120 state.pending?.cancel()
121 state.pending = undefined
122}
123
124/**
125 * Reads the on/off setting and the limit from the store, which every window shares, so a change made in
126 * another window applies here at the next hook that acts on it. A mod turned off there starts its count
127 * again here, as `off` does.
128 */
129async function readSettings($: EngineInterface, state: State): Promise<void> {
130 const was = state.enabled
131 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
132 state.max = await readLimit($)
133 if (was && !state.enabled) resetCount(state)
134}
135
136export const register: Register = on => {
137 const state: State = { enabled: true, max: DEFAULT_MAX_POKES, pokes: 0, limitLogged: false }
138
139 on('session.start', async ($, e, next) => {
140 const r = await next(e)
141 await readSettings($, state)
142 resetCount(state)
143 await $.command.register({
144 name: 'error-poke',
145 description: 'Continue automatically after a turn an API error killed: status, on, off, limit (error-poke)',
146 argumentHint: '[on | off | limit <n>]',
147 immediate: true,
148 })
149 return r
150 })
151
152 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
153 on('command.run', { command: 'error-poke' }, async ($, e) => {
154 const arg = String(e.args ?? '').trim()
155 // The status after a change also shows the other setting as the store holds it.
156 await readSettings($, state)
157 if (arg === 'on' || arg === 'off') {
158 state.enabled = arg === 'on'
159 await $.store.set(ENABLED_KEY, state.enabled)
160 resetCount(state)
161 } else if (arg.startsWith('limit')) {
162 return { text: await setLimit($, state, arg.slice(5).trim()) }
163 } else if (arg !== '') {
164 return { text: USAGE }
165 }
166 return { text: statusText(state.enabled, state.pokes, state.max, state.lastReason) }
167 })
168
169 // Only the origin is read. The prompt text passes through untouched. A prompt whose origin the engine
170 // does not name is left alone, so a missing origin cannot fail this hook and swallow the submit's answer.
171 on('prompt.submit', async (_, e, next) => {
172 const r = await next(e)
173 const kind = (e.origin as { kind?: string } | undefined)?.kind
174 if (kind !== undefined && USER_ORIGINS.includes(kind)) resetCount(state)
175 return r
176 })
177
178 on('turn.complete', async ($, e, next) => {
179 const r = await next(e)
180 if (e.agentId !== undefined) return r
181 state.lastReason = e.reason
182 await readSettings($, state)
183 if (state.enabled) await afterTurn($, state, e.reason)
184 return r
185 })
186}
187hooks/poke.ts 175 lines1/** When a turn an API error killed is worth one continue prompt, and the texts the person reads. */
2
3/** Continue prompts sent for one stretch of failures, until the person sets another limit. */
4export const DEFAULT_MAX_POKES = 99
5
6/** The band `/error-poke limit <n>` takes; a value outside it is refused, never clamped. */
7export const MIN_LIMIT = 1
8export const MAX_LIMIT = 999
9
10/**
11 * The prompt the mod sends. It names the cause and asks the model to carry on where it stopped, because
12 * the interrupted turn's own work is still in the transcript and starting over would repeat it.
13 */
14export const POKE_TEXT =
15 'The previous turn was cut off by an API error, not by me. Continue where you stopped; do not start over. ' +
16 'If you cannot tell how far you got, say so and stop.'
17
18/** What the mod does after one main-loop turn. */
19export type Decision = 'idle' | 'poke' | 'limit'
20
21/**
22 * Whether the turn's end asks for a continue prompt. Only `error` counts: the engine reports a turn the
23 * user interrupted as `aborted` and a model refusal as `refusal`, and neither is a failure to retry.
24 */
25export function decide(reason: string, pokes: number, max: number): Decision {
26 if (reason !== 'error') return 'idle'
27 return pokes >= max ? 'limit' : 'poke'
28}
29
30/** The limit a `/error-poke limit <word>` argument names, or undefined when it is not one. */
31export function limitOf(arg: string): number | undefined {
32 if (!/^\d{1,3}$/.test(arg)) return undefined
33 const n = Number(arg)
34 return n >= MIN_LIMIT && n <= MAX_LIMIT ? n : undefined
35}
36
37/** The answer of `/error-poke limit <n>`, or of an argument it cannot read. */
38export function limitText(limit: number | undefined): string {
39 if (limit === undefined) return `limit expects a whole number from ${MIN_LIMIT} to ${MAX_LIMIT}`
40 return `limit ${limit}: at most ${limit} continue prompt(s) go out for one stretch of failures`
41}
42
43/** The wait before the first continue prompt of a stretch of failures, in milliseconds. */
44export const FIRST_DELAY_MS = 5_000
45
46/** The longest wait between two continue prompts, in milliseconds. */
47export const MAX_DELAY_MS = 300_000
48
49/**
50 * The wait before the `pokes`-th continue prompt: 5 s, then three times the last one, up to 5 minutes.
51 * An overloaded API recovers in seconds, while an error that fails the same way on every try (a context
52 * limit) would otherwise spend the whole limit of prompts back to back.
53 */
54export function pokeDelay(pokes: number): number {
55 return Math.min(FIRST_DELAY_MS * 3 ** Math.max(pokes - 1, 0), MAX_DELAY_MS)
56}
57
58/** A usage limit as the session reports it. */
59export type UsageLimit = { kind: string; percentUsed: number; resetsAt?: string }
60
61/** A continue prompt held back until a usage limit resets: which limit, and when to send. */
62export type LimitWait = { kind: string; until: number }
63
64/** Claude Code's own text of a turn a usage limit stopped: `You've hit your session limit · resets 3:40pm`. */
65const HIT_LIMIT = /hit your .*limit/i
66
67/** Sent this long after the reset, so the first request does not race the limit's own clock. */
68export const RESET_MARGIN_MS = 60_000
69
70/**
71 * Whether the turn died on a usage limit, and until when the continue prompt waits: a limit at 100% or
72 * more waits for its reset (the latest, when several are full), and Claude Code's own limit text with no
73 * limit at 100% waits for the fullest limit's reset. A retry before the reset only fails again, so the
74 * backoff's prompts would all be spent on it. Undefined for any other error, and for a limit whose reset
75 * is not in the future.
76 */
77export function limitWait(limits: readonly UsageLimit[], lastText: string, now: number): LimitWait | undefined {
78 const future = limits.flatMap(l => {
79 const at = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
80 return Number.isFinite(at) && at > now ? [{ limit: l, at }] : []
81 })
82 const full = future.filter(x => x.limit.percentUsed >= 100)
83 if (full.length > 0) {
84 const last = full.reduce((a, b) => (b.at > a.at ? b : a))
85 return { kind: last.limit.kind, until: last.at + RESET_MARGIN_MS }
86 }
87 if (future.length === 0 || !HIT_LIMIT.test(lastText)) return undefined
88 const fullest = future.reduce((a, b) => (b.limit.percentUsed > a.limit.percentUsed ? b : a))
89 return { kind: fullest.limit.kind, until: fullest.at + RESET_MARGIN_MS }
90}
91
92/** A limit's short name: `5h`, `7d`, `spend`, or the engine's own word. */
93function limitName(kind: string): string {
94 return ({ five_hour: '5h', seven_day: '7d', spend_limit: 'spend' } as Record<string, string>)[kind] ?? kind.replaceAll('_', ' ')
95}
96
97/** A local clock time, `15:41`. */
98function clockOf(at: number): string {
99 const d = new Date(at)
100 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
101}
102
103/** The line of a continue prompt held back until a limit resets: the limit red, the count as a poke's. */
104export function limitWaitLines(w: LimitWait, now: number, pokes: number, max: number): Line[] {
105 return [partsLine([
106 part('the turn hit the ', undefined),
107 part(`${limitName(w.kind)} usage limit`, 'error'),
108 part(`, continuing at ${clockOf(w.until)} (in ${waitText(w.until - now)}) `, undefined),
109 part(`(${pokes}/${max})`, countTone(pokes, max)),
110 ])]
111}
112
113/** The wait as the person reads it: seconds under two minutes, whole minutes up to two hours, then hours and minutes. */
114function waitText(ms: number): string {
115 const s = Math.round(ms / 1000)
116 if (s < 120) return `${s} s`
117 const m = Math.round(s / 60)
118 return m < 120 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`
119}
120
121/** The line the person reads when a prompt is scheduled. The engine adds the mod name. */
122export function pokeLog(pokes: number, max: number): string {
123 return pokeLines(pokes, max).map(l => l.text).join('\n')
124}
125
126/** The line the person reads once the mod stops trying. */
127export function limitLog(max: number): string {
128 return limitLines(max).map(l => l.text).join('\n')
129}
130
131/** How the sidebar colours a line or a part of one. */
132type Tone = 'ok' | 'warn' | 'error' | 'dim'
133export type Part = { text: string; kind?: Tone }
134/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
135export type Line = { text: string; kind?: Tone; parts?: Part[] }
136
137const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
138
139/** A line made of parts, its `text` their texts joined. */
140const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
141
142/** The count's colour: yellow once it is within 10% of the limit, faint before. */
143export function countTone(pokes: number, max: number): Tone {
144 return pokes >= max * 0.9 ? 'warn' : 'dim'
145}
146
147/** `pokeLog` as a sidebar line: `API error` red, the count faint or yellow near the limit. */
148export function pokeLines(pokes: number, max: number): Line[] {
149 return [partsLine([
150 part('the turn died on an ', undefined),
151 part('API error', 'error'),
152 part(`, continuing in ${waitText(pokeDelay(pokes))} `, undefined),
153 part(`(${pokes}/${max})`, countTone(pokes, max)),
154 ])]
155}
156
157/** `limitLog` as a sidebar line: the stop red, the way to reset faint. */
158export function limitLines(max: number): Line[] {
159 return [partsLine([part(`stopped after ${max} continue prompts`, 'error'), part('; the API keeps failing. ', undefined), part('Send a prompt to reset the count.', 'dim')])]
160}
161
162/** A `<head>: <detail>` event as a sidebar line: the head red, the detail in the default colour. */
163export function eventLines(head: string, detail: string): Line[] {
164 return [partsLine([part(head, 'error'), part(`: ${detail}`, undefined)])]
165}
166
167/**
168 * The `/error-poke` answer. `reason` is how the last main-loop turn ended, so the person can tell whether
169 * the engine reported their own error as `error` at all; it is empty until a turn has ended.
170 */
171export function statusText(enabled: boolean, pokes: number, max: number, reason?: string): string {
172 const last = reason === undefined ? 'no turn has ended yet' : `last turn: ${reason}`
173 return `${enabled ? 'on' : 'off'} · ${pokes}/${max} continue prompts since your last prompt · ${last}`
174}
175