SLOPSHOPPER

auto-continue

Carry the work on by itself: once a used-up quota resets within the wait you allow, or at a time you set

newspinnercommandtoaststatustimer
v0.1.0no licenseupdated 2026-10-21JunWang666/cc-cockpit/mods/auto-continue
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-continue
› 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 › /auto-continue ⎿ auto-continue: 额度自动继续:关 ⎿ auto-continue: 定时继续:无 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-cockpit

Claude Code mods for a terminal cockpit: a stats band above the prompt and quick navigation through your own messages. Needs Claude Code 2.1.287 or newer.

ModWhat it does
quota-bandA divider between the transcript and the prompt, and one row under it: turn number, output speed (tok/s), turn and session cost, prompt-cache countdown, 5-hour and weekly quota with reset times, and context usage.
welcomeWhen a new session starts, a card above the prompt shows the project's branch, uncommitted changes, ahead/behind, the last three commits, a one-line gist of README.md / AGENTS.md / CLAUDE.md (press one to read its opening lines), what the last session did (its recap and the files it edited) and the project's five most recent sessions; press a digit in the empty prompt to resume one. It hides on your first message; /welcome brings it back.
desktop-cockpitThe three mods above, drawn for Claude Code Desktop. Above the prompt: the stats row (turn, tok/s, cost, cache minutes left, quota and context as bars) and, in a new session, the welcome card. `/cockpit [usage\project\prompts] [keyword]` (or the 驾驶舱 › button) opens a pane with three tabs: usage with reset times, the project's git state, docs and last session, and your messages, each one click to scroll to. On desktop, quota-band and welcome leave the band to it; in a terminal it draws nothing above the prompt. Needs no Python.
auto-continue`/auto-continue on [1d\12h\90m] turns on auto-continue for the session (off turns it off, no argument shows its state). When a turn stops because the weekly (or 5-hour) quota is used up, it waits for that window to reset and then sends a prompt to carry the work on — but only if the reset comes within the wait you allowed, counted from when the quota ran out (24 hours by default). While waiting, the status line shows when it will continue and the prompt footer shows auto-continue. It also continues at a set time: /auto-continue at 14:30 [message] (today, or tomorrow once passed), at 10/22 09:00 [message] or in 2h [message] sends the message (continue by default) then; cancel drops it. Options: on by default, the longest wait, the prompts it sends, and whether to wait for the weekly quota only. Works in the terminal and the desktop app. With quota-band or desktop-cockpit installed, its state also shows in their stats row beside the quota (⏳ 10:01 自动续, ⏰ 14:30 定时续, ⟳ 自动续), and desktop-cockpit's usage tab has a picker for the longest wait (or off), a field for the message to send, a picker of set times (30 minutes to 5 hours, the next 9:00), a field for any time (14:30, 10/22 09:00, 2h`) and a button to cancel it.
prompt-jump/prompts lists the messages you sent in this session; press a number to scroll the transcript to one. 0 or /bottom jumps back to the end. Scrolling needs the fullscreen layout (/tui fullscreen).

Install

/plugin install quota-band --marketplace JunWang666/cc-cockpit
/plugin install prompt-jump --marketplace JunWang666/cc-cockpit
/plugin install welcome --marketplace JunWang666/cc-cockpit
/plugin install desktop-cockpit --marketplace JunWang666/cc-cockpit
/plugin install auto-continue --marketplace JunWang666/cc-cockpit

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with JunWang666/cc-cockpit, then Add plugin.

Develop

Each mod is in mods/<name>/. Check one with claude plugin validate mods/<name> and claude plugin test mods/<name>, then /reload-plugins in a session.

Source 2 files
hooks/register.ts 293 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AutoContinueWait } from '../types'
5
6const isEnabledAtom = atom({ plugin: 'auto-continue', key: 'isEnabled' } as const, null)
7const maxWaitAtom = atom({ plugin: 'auto-continue', key: 'maxWaitMs' } as const, null)
8const limitedSince = atom({ plugin: 'auto-continue', key: 'limitedSince' } as const, null)
9const waitAtom = atom({ plugin: 'auto-continue', key: 'wait' } as const, null)
10const attempts = atom({ plugin: 'auto-continue', key: 'attempts' } as const, 0)
11const scheduledAtom = atom({ plugin: 'auto-continue', key: 'scheduled' } as const, null)
12
13const HOUR = 60 * 60 * 1000
14// Sent a little after the reset, so the new window is surely open.
15const MARGIN_MS = 2 * 60 * 1000
16// With no reset time reported, try again this often until the wait allowed runs out.
17const POLL_MS = 30 * 60 * 1000
18// Prompts sent in a row that hit the limit again before giving up.
19const MAX_ATTEMPTS = 3
20const MODE = 'auto-continue'
21const DEFAULT_PROMPT = '额度已刷新,请从中断的地方继续完成刚才的任务。'
22const DEFAULT_SCHEDULED_PROMPT = 'continue'
23const LABELS: Record<string, string> = { five_hour: '5 小时额度', seven_day: '周额度', spend_limit: '花费额度' }
24const USAGE =
25  '用法:/auto-continue on [1d|12h|90m] · off · at 14:30 [消息] · at 10/22 09:00 [消息] · in 2h [消息] · cancel · 不带参数看状态。'
26
27// The manifest's options, read once per load by register.
28let config = {
29  isEnabledByDefault: false,
30  maxWaitMs: 24 * HOUR,
31  prompt: DEFAULT_PROMPT,
32  scheduledPrompt: DEFAULT_SCHEDULED_PROMPT,
33  isWeeklyOnly: false,
34}
35// The module's own timers: a reload drops them, and session.start arms them again from $.state.
36let timer: Timer | undefined
37let scheduleTimer: Timer | undefined
38
39const pad = (n: number) => String(n).padStart(2, '0')
40const when = (ms: number) => {
41  const d = new Date(ms)
42  return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
43}
44const span = (ms: number) => {
45  const min = Math.round(ms / 60000)
46  if (min < 60) return `${min} 分钟`
47  if (min < 24 * 60) return `${Math.round((min / 60) * 10) / 10} 小时`
48  return `${Math.round((min / 1440) * 10) / 10} 天`
49}
50const label = (kind: string | null) => (kind ? (LABELS[kind] ?? kind) : '额度')
51const watched = (kind: string) => !config.isWeeklyOnly || kind === 'seven_day'
52
53// `1d`, `12h`, `90m`, `1.5天`, `8小时`; a bare number is hours.
54const parseSpan = (text: string) => {
55  const m = /^(\d+(?:\.\d+)?)\s*(d|h|m|天|小时|分钟|分)?$/i.exec(text.trim())
56  if (!m) return null
57  const n = Number(m[1])
58  const unit = (m[2] ?? 'h').toLowerCase()
59  const ms = unit === 'd' || unit === '天' ? n * 24 * HOUR : unit === 'm' || unit.startsWith('分') ? n * 60000 : n * HOUR
60  return ms > 0 ? ms : null
61}
62
63// `14:30` (today, or tomorrow once it has passed) or `10/22 14:30` (this year, or next once passed), local time.
64const parseTime = (text: string, now: number) => {
65  const m = /^(?:(\d{1,2})[/-](\d{1,2})\s+)?(\d{1,2})[::](\d{2})$/.exec(text.trim())
66  if (!m) return null
67  const [hour, minute] = [Number(m[3]), Number(m[4])]
68  if (hour > 23 || minute > 59) return null
69  const d = new Date(now)
70  if (m[1]) d.setMonth(Number(m[1]) - 1, Number(m[2]))
71  d.setHours(hour, minute, 0, 0)
72  if (d.getTime() <= now) m[1] ? d.setFullYear(d.getFullYear() + 1) : d.setDate(d.getDate() + 1)
73  return d.getTime()
74}
75
76async function isEnabled($: EngineInterface) {
77  return (await read($, isEnabledAtom)) ?? config.isEnabledByDefault
78}
79
80async function maxWait($: EngineInterface) {
81  return (await read($, maxWaitAtom)) ?? config.maxWaitMs
82}
83
84async function showStatus($: EngineInterface) {
85  const wait = await read($, waitAtom)
86  const scheduled = await read($, scheduledAtom)
87  const parts = [
88    wait && (await isEnabled($)) ? `⏳ ${label(wait.kind)}用完 · ${when(wait.wakeAt)} 自动继续` : null,
89    scheduled ? `⏰ ${when(scheduled.at)} 定时继续` : null,
90  ].filter(Boolean)
91  $.ui.status(parts.length > 0 ? parts.join('  ') : undefined)
92}
93
94function armSchedule($: EngineInterface, at: number, now: number) {
95  scheduleTimer?.cancel()
96  scheduleTimer = $.clock.after(Math.max(1000, at - now), () => void fireSchedule($).catch(() => undefined))
97}
98
99async function cancelSchedule($: EngineInterface) {
100  scheduleTimer?.cancel()
101  scheduleTimer = undefined
102  await update($, scheduledAtom, () => null)
103  await showStatus($)
104}
105
106async function fireSchedule($: EngineInterface) {
107  scheduleTimer = undefined
108  const scheduled = await read($, scheduledAtom)
109  if (scheduled === null) return
110  await update($, scheduledAtom, () => null)
111  await showStatus($)
112  $.ui.toast('到点了,定时继续')
113  await $.prompt.submit({ text: scheduled.text }).catch((err: unknown) => $.ui.toast(`定时继续没能发出消息:${String(err)}`))
114}
115
116// `at 14:30 [text]`, `at 10/22 09:00 [text]` or `in 2h [text]`: send the text (or the option's) then.
117async function schedule($: EngineInterface, verb: 'at' | 'in', words: string[]) {
118  const now = await $.clock.now()
119  const hasDate = verb === 'at' && /^\d{1,2}[/-]\d{1,2}$/.test(words[0] ?? '')
120  const timeWords = hasDate ? 2 : 1
121  const given = words.slice(0, timeWords).join(' ')
122  const at = verb === 'at' ? parseTime(given, now) : (s => (s === null ? null : now + s))(parseSpan(given))
123  if (at === null) {
124    return verb === 'at'
125      ? `看不懂时间「${given}」:写成 14:30 或 10/22 09:00 这样。`
126      : `看不懂时长「${given}」:写成 2h、90m、1d 这样。`
127  }
128  const text = words.slice(timeWords).join(' ').trim() || config.scheduledPrompt
129  await update($, scheduledAtom, () => ({ at, text }))
130  armSchedule($, at, now)
131  await showStatus($)
132  return `已定时:${when(at)}(${span(at - now)}后)发送「${text}」。/auto-continue cancel 取消。`
133}
134
135async function clear($: EngineInterface) {
136  timer?.cancel()
137  timer = undefined
138  await update($, waitAtom, () => null)
139  await update($, limitedSince, () => null)
140  await update($, attempts, () => 0)
141  await showStatus($)
142}
143
144function arm($: EngineInterface, wait: AutoContinueWait, at: number) {
145  timer?.cancel()
146  timer = $.clock.after(Math.max(1000, wait.wakeAt - at), () => void fire($))
147}
148
149async function fire($: EngineInterface) {
150  timer = undefined
151  if (!(await isEnabled($)) || (await read($, waitAtom)) === null) return
152  await update($, waitAtom, () => null)
153  await update($, attempts, n => n + 1)
154  await showStatus($)
155  $.ui.toast('额度已刷新,自动继续')
156  await $.prompt.submit({ text: config.prompt }).catch((err: unknown) => $.ui.toast(`自动继续没能发出消息:${String(err)}`))
157}
158
159// The quota ran out: wait for the window that holds it to reset, if that comes within the wait allowed.
160async function plan($: EngineInterface) {
161  if (!(await isEnabled($))) return
162  const at = await $.clock.now()
163  if ((await read($, attempts)) >= MAX_ATTEMPTS) {
164    $.ui.toast(`自动继续了 ${MAX_ATTEMPTS} 次仍然受限,已停止等待`)
165    await clear($)
166    return
167  }
168  const since = (await read($, limitedSince)) ?? at
169  await update($, limitedSince, () => since)
170  const allowed = await maxWait($)
171
172  const { rateLimits } = await $.session.usage()
173  const used = rateLimits.filter(r => r.percentUsed >= 100)
174  const out = used
175    .filter(r => watched(r.kind) && r.resetsAt && !Number.isNaN(Date.parse(r.resetsAt)))
176    .map(r => ({ kind: r.kind, resetAt: Date.parse(r.resetsAt as string) }))
177    .sort((a, b) => b.resetAt - a.resetAt)[0]
178  if (!out && used.length > 0 && !used.some(r => watched(r.kind))) {
179    // only another window ran out, and only the weekly one is waited for
180    await clear($)
181    return
182  }
183
184  const wait: AutoContinueWait = out
185    ? { wakeAt: Math.max(out.resetAt, at) + MARGIN_MS, kind: out.kind }
186    : { wakeAt: at + POLL_MS, kind: null }
187  if (wait.wakeAt > since + allowed) {
188    const what = out ? `${label(out.kind)}要到 ${when(out.resetAt)} 才刷新` : '额度一直没有刷新'
189    $.ui.toast(`${what},超出允许的等待(${span(allowed)}),不再自动继续`, { timeoutMs: 10_000 })
190    await clear($)
191    return
192  }
193  await update($, waitAtom, () => wait)
194  arm($, wait, at)
195  await showStatus($)
196  $.ui.toast(
197    out ? `${label(out.kind)}用完了,${when(out.resetAt)} 刷新后自动继续` : `额度受限,${span(POLL_MS)}后自动再试`,
198    { timeoutMs: 8000 },
199  )
200}
201
202export const register: Register = (on, options) => {
203  config = {
204    isEnabledByDefault: Boolean(options.enabledByDefault),
205    maxWaitMs: Math.max(0, Number(options.maxWaitHours ?? 24)) * HOUR,
206    prompt: String(options.prompt ?? '').trim() || DEFAULT_PROMPT,
207    scheduledPrompt: String(options.scheduledPrompt ?? '').trim() || DEFAULT_SCHEDULED_PROMPT,
208    isWeeklyOnly: options.windows === 'seven_day',
209  }
210
211  on('session.start', async ($, e, next) => {
212    const started = await next(e)
213    await $.command.register({
214      name: 'auto-continue',
215      description: '额度刷新后自动继续(on [时长] / off),或到点定时继续(at 14:30 / in 2h [消息],cancel 取消)',
216      argumentHint: '[on [1d] | off | at 14:30 [消息] | in 2h [消息] | cancel]',
217    })
218    // Settle the option's default into the state, so the quota bands that read it see on or off.
219    if ((await read($, isEnabledAtom)) === null) await update($, isEnabledAtom, () => config.isEnabledByDefault)
220    if ((await read($, maxWaitAtom)) === null) await update($, maxWaitAtom, () => config.maxWaitMs)
221    // A reload, or a session resumed while waiting: arm the waits again.
222    const now = await $.clock.now()
223    const wait = await read($, waitAtom)
224    if (wait && (await isEnabled($))) arm($, wait, now)
225    const scheduled = await read($, scheduledAtom)
226    if (scheduled) armSchedule($, scheduled.at, now)
227    await showStatus($)
228    return started
229  })
230
231  on('command.run', { command: 'auto-continue' }, async ($, e) => {
232    const [verb = '', ...rest] = (e.args ?? '').trim().split(/\s+/).filter(Boolean)
233    if (verb === 'off') {
234      await update($, isEnabledAtom, () => false)
235      await clear($)
236      return { text: '自动继续已关闭。' }
237    }
238    if (verb === 'on' || parseSpan(verb) !== null) {
239      const given = verb === 'on' ? rest.join(' ') : verb
240      const ms = given ? parseSpan(given) : null
241      if (given && ms === null) return { text: `看不懂时长「${given}」:写成 1d、12h、90m 这样。` }
242      await update($, isEnabledAtom, () => true)
243      if (ms !== null) await update($, maxWaitAtom, () => ms)
244      await showStatus($)
245      return { text: `自动继续已开启:额度用完后 ${span(await maxWait($))} 内刷新,就发送「${config.prompt}」。` }
246    }
247    if (verb === 'at' || verb === 'in') return { text: await schedule($, verb, rest) }
248    if (verb === 'cancel') {
249      const had = (await read($, scheduledAtom)) !== null
250      await cancelSchedule($)
251      return { text: had ? '已取消定时继续。' : '没有定时继续要取消。' }
252    }
253    if (verb) return { text: USAGE }
254
255    const wait = await read($, waitAtom)
256    const scheduled = await read($, scheduledAtom)
257    const state = !(await isEnabled($))
258      ? '额度自动继续:关'
259      : wait
260        ? `额度自动继续:开,正在等待,${when(wait.wakeAt)} 发送「${config.prompt}」`
261        : `额度自动继续:开,额度用完后 ${span(await maxWait($))} 内刷新就继续`
262    const timed = scheduled ? `定时继续:${when(scheduled.at)} 发送「${scheduled.text}」` : '定时继续:无'
263    return { text: `${state}\n${timed}` }
264  })
265
266  // The main conversation stopped on a used-up quota.
267  on('classic.StopFailure', async ($, e, next) => {
268    const done = await next(e)
269    if (e.error === 'rate_limit' && e.agent_id === undefined) await plan($).catch(() => undefined)
270    return done
271  })
272
273  on('turn.complete', async ($, e, next) => {
274    const done = await next(e)
275    if (e.agentId !== undefined) return done
276    if (e.reason === 'answer' && ((await read($, limitedSince)) !== null || (await read($, attempts)) > 0)) {
277      // the work carries on: the stretch is over
278      await clear($)
279    } else if (e.reason === 'error' && (await read($, waitAtom)) === null) {
280      // in case StopFailure was not raised: an error while a watched window is used up
281      const { rateLimits } = await $.session.usage()
282      if (rateLimits.some(r => r.percentUsed >= 100 && watched(r.kind))) await plan($)
283    }
284    return done
285  })
286
287  // A mode label in the prompt footer while it is on (terminal and desktop).
288  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
289    if (!(await isEnabled($)) || e.props.modes.includes(MODE)) return next(e)
290    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, MODE] } })
291  })
292}
293
types/index.d.ts 22 lines
1export type AutoContinueWait = {
2  // when the prompt is sent: the window's reset, plus a margin
3  wakeAt: number
4  // the window that ran out (`seven_day`, `five_hour`), or null when the reset time was not reported
5  kind: string | null
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'auto-continue': {
11      isEnabled: boolean | null
12      maxWaitMs: number | null
13      // when the quota first ran out in this stretch; the wait allowed counts from here
14      limitedSince: number | null
15      wait: AutoContinueWait | null
16      attempts: number
17      // a continue sent at a set time: `/auto-continue at 14:30` or `in 2h`
18      scheduled: { at: number; text: string } | null
19    }
20  }
21}
22