SLOPSHOPPER

error-poke

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…

newcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · error-poke
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /error-poke ⎿ error-poke: on · 0/99 continue prompts since your last prompt · last turn: answer ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

error-poke

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

What it does

  1. It watches turn.complete of the main loop. A subagent's turn is left alone.
  2. It acts on one kind of end only: 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.
  3. It sends this prompt, which runs once the session is idle:

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.

  1. The prompt waits before it goes out, longer after each failure in a row: 5 s, 15 s, 45 s, 135 s, then 5 minutes each. An overloaded API usually recovers in seconds, and an error that fails the same way on every try (the context limit) does not burn the whole limit back to back. A prompt of yours during the wait, or /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)

  1. At most 99 prompts go out for one stretch of failures, or as many as /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.

  1. A prompt of your own (from the composer, the bridge or the SDK) resets the count, so the next failure starts from 1 again.
  2. With the sidebar open, these lines go into its stream instead, and the transcript stays clean. There 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.
  3. The prompt runs the mod's own markdown command /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.
  4. A plugin prompt the engine refuses (the session is busy, a stop is pending) is reported as well, and no count is lost.

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.

Command

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

Install

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.

After installing

  1. Restart Claude Code.

What it can reach

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.

  1. Reads: how each main-loop turn ended, and the origin of each prompt; after a turn an API error ended, the session's usage limits and the last assistant text; no file, no command
  2. Runs: nothing
  3. Sends: one fixed continue prompt to your own session, and one line to the transcript; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the limit; the count lives in memory for one stretch of failures
  5. Hostile input: the prompt text is a constant in the mod; no transcript or API text is copied into it

Limits

  • The mod goes by the engine's reason. An error the engine reports as something other than error gets no prompt; /error-poke prints the value so you can tell.
  • A turn that failed before any output is continued the same way. The model may answer that it cannot tell how far it got, which is what the prompt asks for.
  • The count is per session and lives in memory, so a restart starts at 0.
  • Nothing is retried at the API level. The mod starts a new turn, so the failed turn's tokens stay spent.

Development

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

Source 2 files
hooks/register.ts 187 lines
1import 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}
187
hooks/poke.ts 175 lines
1/** 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