SLOPSHOPPER

limit-resume

Resume an interrupted session after its usage limit resets

newbandguardcommandprompttool
★ 1v0.7.0MITupdated 2026-10-23ivbala1/limit-resume
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-resume
› 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 › /limit-resume ⎿ limit-resume: limit-resume 0.7.0 loaded. Session preview-session. Surfaces: terminal. On. Ready to wait for the limit. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

limit-resume

A mod for Claude Code that continues an interrupted session by itself once your subscription usage limit resets. Before the limit runs out it asks Claude to save a short checkpoint (goal, what is done, what remains, the next step), and after the reset it sends that same session a request to carry on, with the checkpoint attached.

It works in the Code tab of the Claude desktop app and in an interactive terminal session. Headless runs (claude -p, SDK scripts, bots, timers) are never touched.

Install

In a terminal session:

/plugin install limit-resume --marketplace ivbala1/limit-resume

Answer y to add the marketplace and pick the user scope. New sessions get the mod on their own; in a session that is already open, send /reload-plugins, then /limit-resume status to see it loaded.

What it does

  • A budget line on every prompt. Each of your messages carries one line for Claude: how much of the five-hour and weekly windows is used, when each resets, and a forecast from the pace of the last hour of this session ("lasts until the reset" or "runs out around 14:05"). A short rule on how to spend the rest is added only when the forecast falls short or a window is at 70% or more.
  • A checkpoint before the limit. From 90% Claude is asked once to call the mod's checkpoint tool with six short fields and two flags: continuation (auto: can go on alone, owner: waits for you, done: finished) and verifyFirst (the last action may have run partly). Only the latest checkpoint of the session is kept, in the plugin's own store. No transcript, no command output.
  • A continuation after the reset. When the limit is hit, the mod waits for the reset time the engine reports, plus a minute, and sends the session a request to continue. The request always starts with "check how the last action ended and do not repeat it". A checkpoint saved before your last own message is not attached: it describes an older task. A task marked owner or done is not continued.
  • Retries with a limit. A continuation the engine refuses from its queue is retried with backoff, three attempts at most. A refusal because you stopped the session is final.
  • You stay in charge. Typing in the prompt box, sending anything else, editing a message, /clear or /limit-resume off cancels the wait. The mod never interrupts running commands or subagents and never changes permissions.

All open sessions that hit the limit continue at once after the reset; there is no queue.

Compared with the built-in setting

Claude Code has its own setting, autoContinueAtUsageLimit ("Continue automatically at usage limit"): when a usage limit stops the session, it waits for the reset and continues the task. If that is all you need, use it. The mod adds:

  • Every session, not just the visible one. In the author's use of the desktop app with several sessions running at once, only the session open on screen went on after the reset; the others stayed idle until opened. The mod continues each session that hit the limit.
  • A checkpoint written before the limit. The goal, what is done, what is left and the next step are saved while there is still budget, and attached to the continuation, so the session picks up from a note rather than from a cut off turn.
  • It knows when not to continue. A task marked as waiting for you or finished is left alone, and every continuation starts with "check how the last action ended and do not repeat it".
  • The budget line. Usage, reset times and a forecast on every prompt, so Claude can plan the rest of the window before it runs out.

Using the mod together with the built-in setting has not been tested; turn one of them off.

Settings

Set them with /plugin configure limit-resume@limit-resume, or in settings.json; unset options keep the defaults below:

{
  "pluginConfigs": {
    "limit-resume@limit-resume": {
      "options": { "language": "en", "timeZone": "", "reserveFor": "", "economyHint": "" }
    }
  }
}
OptionDefaultMeaning
languageenen or ru: the mod's messages and its notes to Claude
timeZonesystem zoneIANA zone for clock times, such as Europe/Berlin
reserveForemptyKeep the last part of the limit for marked sessions (see below); empty turns it off
economyHintemptyYour own advice added to the spending rule, such as "hand scouting to a cheaper model"

Reserve

If something of yours must always have budget left (an assistant you talk to, a scheduled job), set reserveFor to its name as it reads in "the rest is kept for ...". Then from 97% of the five-hour window or 99% of the week an ordinary session saves its checkpoint, ends its turn and waits for the reset, and only sessions marked with /limit-resume reserve keep working. The reserve holds right up to the reset, and a session paused by the weekly reserve waits for the weekly reset even if the five-hour one comes first. Turning the reserve off releases a paused session within a minute. The reserve is approximate: percentages lag behind real usage and running commands are not stopped.

Within 15 minutes of a window's reset the spending rule tells Claude it no longer needs to economise on that window.

Commands

CommandEffect
/limit-resume statusVersion, session and state
/limit-resume on / offTurn the mod on or off in this session; the choice is kept
/limit-resume armWait for the reset in a session that has already stopped
/limit-resume at 02:40Wait until that time in your zone, or give an ISO date with an offset
/limit-resume checkpointShow the latest checkpoint
/limit-resume reserve / reserve offLet this session spend the reserve, or stop that

These commands are answered by the mod itself, with no model call.

Limits

  • The computer has to be awake and the app or terminal session open; the mod does not start a closed app.
  • Usage numbers come from the engine without network calls and are the last estimate it received. A large request or parallel subagents can run out the rest before the checkpoint is saved.
  • A new session does not reset the shared limit, and the mod does not compact context, open new chats or switch models.
  • Saving the checkpoint and ending the turn rely on Claude following the instruction.

Development

claude plugin validate --strict .
claude plugin test .

MIT License.

Source 4 files
hooks/register.js 472 lines
1import { CHECKPOINT_TOOL, CHECKPOINT_FIELDS, CONTINUATIONS, budgetWindow, budgetLine, checkpointNotice, cleanCheckpoint, trackPace, RESERVE } from './budget.js';
2import { resumeText as baseResumeText, GRACE_MS, MAX_ATTEMPTS, MAX_WAIT_MS, isLimitError, exhaustedReset, clockTime, textReset, nextDeadline } from './logic.js';
3import { DEFAULTS, settings, t, timeLabel as zonedLabel } from './config.js';
4
5const VERSION = '0.7.0';
6let cfg = DEFAULTS;
7const say = (key, vars) => t(cfg, key, vars);
8const timeLabel = ms => zonedLabel(ms, cfg);
9
10let sessionId = null;
11let enabled = false;
12let interactive = false;
13let working = false;
14let pending = null;
15let attempts = 0;
16let generation = 0;
17let timer = null;
18let firing = false;
19let ownSubmission = false;
20let lastError = '';
21let status = '';
22let monitor = false;
23let selectedForBackground = false;
24let warned = {};
25let checkpoint = null;
26let latestBudget = null;
27// Recent samples of the five-hour window: the pace for the forecast.
28let paceSamples = [];
29let pauseAfterTurn = false;
30let optedOut = false;
31// Set explicitly by the owner: this session may spend the configured reserve.
32let reserved = false;
33// Number of turns that were not our continuation: a checkpoint saved under an earlier number describes an older task.
34let ownerTurn = 0;
35
36function cancelTimer() {
37  generation += 1;
38  timer?.cancel();
39  timer = null;
40  pending = null;
41  firing = false;
42}
43
44async function save($) {
45  if (!sessionId) return;
46  await $.store.set(`session:${sessionId}`, { enabled, optedOut, reserved, paceSamples, ownerTurn, ownSubmission, pending, attempts, monitor, selectedForBackground, warned, checkpoint, pauseAfterTurn, latestBudget });
47}
48
49async function show($, message) {
50  status = message;
51  $.ui.invalidate('ui.render');
52  await $.ui.log(message);
53}
54
55async function cancel($, message, resetAttempts = true) {
56  cancelTimer();
57  if (resetAttempts) attempts = 0;
58  await save($);
59  if (message) await show($, message);
60}
61
62async function ensureSession($) {
63  const id = await $.session.id();
64  if (id !== sessionId) await load($, id);
65  // On by default: an interactive session joins background resume unless the owner opted out.
66  if (interactive && enabled && !selectedForBackground) {
67    monitor = true;
68    selectedForBackground = true;
69    await registerCheckpoint($);
70    await save($);
71  }
72}
73
74async function load($, id) {
75  cancelTimer();
76  sessionId = id;
77  working = false;
78  ownSubmission = false;
79  lastError = '';
80  const saved = await $.store.get(`session:${id}`);
81  optedOut = saved?.optedOut === true;
82  reserved = saved?.reserved === true;
83  ownerTurn = Number.isInteger(saved?.ownerTurn) ? saved.ownerTurn : 0;
84  enabled = !optedOut;
85  monitor = enabled;
86  // A continuation submitted before a reload is still ours when its turn starts.
87  ownSubmission = enabled && saved?.ownSubmission === true;
88  selectedForBackground = saved?.selectedForBackground === true;
89  if (enabled && selectedForBackground) interactive = true;
90  warned = saved?.warned ?? {};
91  checkpoint = saved?.checkpoint ?? null;
92  // A reserve turned off in the options must not come back from the store.
93  pauseAfterTurn = Boolean(cfg.reserveFor) && saved?.pauseAfterTurn === true;
94  latestBudget = saved?.latestBudget?.level === RESERVE && !cfg.reserveFor ? null : saved?.latestBudget ?? null;
95  paceSamples = Array.isArray(saved?.paceSamples) ? saved.paceSamples : [];
96  attempts = Number.isInteger(saved?.attempts) ? Math.min(saved.attempts, MAX_ATTEMPTS) : 0;
97  const now = await $.clock.now();
98  if (enabled && saved?.pending && Number.isFinite(saved.pending.deadline)
99      && saved.pending.deadline <= now + MAX_WAIT_MS && attempts < MAX_ATTEMPTS) {
100    // A pause for a reserve that is turned off now: nothing to wait for, continue shortly.
101    const released = saved.pending.reserve && !cfg.reserveFor;
102    pending = released ? { resetAt: now, deadline: Math.min(saved.pending.deadline, now + GRACE_MS) } : saved.pending;
103    schedule($);
104    // Saved at once: a later reload must not push the shortened deadline again.
105    if (released) await save($);
106    await show($, say('resumeAt', { time: timeLabel(pending.deadline) }));
107  }
108}
109
110function schedule($) {
111  const token = generation;
112  timer?.cancel();
113  // A short local poll also handles a computer waking after the reset.
114  timer = $.clock.every(15_000, async () => {
115    try { await check($, token); }
116    catch { if (token === generation) await cancel($, say('checkFailed')); }
117  });
118}
119
120async function check($, token) {
121  if (token !== generation || !pending || !enabled || !interactive || working || firing) return;
122  const now = await $.clock.now();
123  if (now < pending.deadline) return;
124  firing = true;
125  const [draft, usage] = await Promise.all([$.prompt.read(), $.session.usage()]);
126  if (token !== generation || !pending || !enabled || working) { firing = false; return; }
127  if (draft.text.trim()) {
128    await cancel($, say('draftCancel'));
129    return;
130  }
131  const later = exhaustedReset(usage.rateLimits);
132  if (later !== null && later + 60_000 > now && later > pending.resetAt) {
133    const deadline = nextDeadline(later, now, attempts);
134    if (deadline === null) { await cancel($, say('tooFar')); return; }
135    pending = { resetAt: later, deadline };
136    firing = false;
137    await save($);
138    await show($, say('stillLimited', { time: timeLabel(deadline) }));
139    return;
140  }
141  if (attempts >= MAX_ATTEMPTS) { await cancel($, say('attemptsOut'), false); return; }
142  if (freshCheckpoint() && ['owner', 'done'].includes(checkpoint.continuation)) {
143    await cancel($, say(checkpoint.continuation === 'owner' ? 'ownerWaits' : 'taskDone'));
144    return;
145  }
146  attempts += 1;
147  const resetAt = pending.resetAt;
148  // Claim the attempt before submitting: a reload cannot send it twice. The
149  // engine keeps an accepted prompt queued, so a reload does not lose it either.
150  cancelTimer();
151  ownSubmission = true;
152  const claim = generation;
153  await save($);
154  await show($, say('sending', { n: attempts, max: MAX_ATTEMPTS }));
155  // A new request or `off` during these awaits cancels the claim.
156  if (claim !== generation || !enabled || working) { ownSubmission = false; return; }
157  let accepted;
158  try {
159    // Resolves when the continuation's turn starts (mods API), or with { drop }.
160    accepted = await $.prompt.submit({ text: resumeText() });
161  } catch (err) {
162    accepted = { drop: String(err?.message ?? err) };
163  }
164  if (accepted && accepted.drop === undefined) return;
165  // The owner may have written or turned the mod off while submit waited.
166  if (claim !== generation || !enabled) return;
167  ownSubmission = false;
168  const reason = String(accepted?.drop ?? '');
169  // The owner stopped the session: continuing on our own would override that.
170  if (reason.includes('the session was stopped')) {
171    await cancel($, say('stopped'), false);
172    return;
173  }
174  await retryLater($, resetAt, say('notAccepted'), claim);
175}
176
177async function retryLater($, resetAt, reason, claim) {
178  if (attempts >= MAX_ATTEMPTS) { await cancel($, say('reasonOut', { reason }), false); return; }
179  const now = await $.clock.now();
180  if (claim !== generation || !enabled) return;
181  cancelTimer();
182  pending = { resetAt, deadline: now + 60_000 * 2 ** attempts };
183  schedule($);
184  await save($);
185  await show($, say('retryAt', { reason, time: timeLabel(pending.deadline) }));
186}
187
188function freshCheckpoint() {
189  return Boolean(checkpoint) && checkpoint.turn === ownerTurn;
190}
191
192// Attach the checkpoint only if it was saved after the owner's last message.
193function resumeText() {
194  const base = baseResumeText(cfg);
195  if (!freshCheckpoint()) return base;
196  const first = checkpoint.verifyFirst ? '\n\n' + say('verifyFirst') : '';
197  return base + first + '\n\n' + say('attach') + JSON.stringify(checkpoint);
198}
199
200async function arm($, text, explicit = false, override = null, reserve = false) {
201  await ensureSession($);
202  if (!interactive || !enabled || working) return false;
203  if (attempts >= MAX_ATTEMPTS) { await show($, say('attemptsArm')); return false; }
204  if (!explicit && !isLimitError(text)) return false;
205  const now = await $.clock.now();
206  const usage = await $.session.usage();
207  const resetAt = override ?? exhaustedReset(usage.rateLimits) ?? textReset(text, now, cfg.zone);
208  const deadline = nextDeadline(resetAt, now, attempts);
209  if (resetAt === null || deadline === null) {
210    await show($, say('unknownReset', { zone: cfg.zone }));
211    return false;
212  }
213  if (pending?.resetAt === resetAt) return true;
214  cancelTimer();
215  pending = reserve ? { resetAt, deadline, reserve } : { resetAt, deadline };
216  schedule($);
217  await save($);
218  await show($, say('resumeAt', { time: timeLabel(deadline) }));
219  return true;
220}
221
222async function registerCheckpoint($) {
223await $.tool.register({ name: 'checkpoint', description: say('toolDescription'), isDeferred: false, inputSchema: { type: 'object', properties: { ...Object.fromEntries(CHECKPOINT_FIELDS.map(k => [k, { type: 'string', maxLength: 4000 }])), continuation: { type: 'string', enum: CONTINUATIONS, description: say('toolContinuation') }, verifyFirst: { type: 'boolean', description: say('toolVerify') } }, required: [...CHECKPOINT_FIELDS, 'continuation', 'verifyFirst'], additionalProperties: false } });
224}
225
226async function control($, raw) {
227    await ensureSession($);
228    const args = raw.trim();
229    if (args === 'off') {
230      optedOut = true;
231      enabled = false;
232      monitor = false;
233      selectedForBackground = false;
234      pauseAfterTurn = false;
235      await cancel($, say('off'));
236    } else if (args === 'arm' || args.startsWith('at ')) {
237      if (working) return { text: say('busy') };
238      let override = null;
239      if (args.startsWith('at ')) {
240        const time = args.slice(3).trim();
241        const now = await $.clock.now();
242        override = /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(time) ? Date.parse(time) : clockTime(time, now, cfg.zone);
243        if (!Number.isFinite(override)) return { text: say('badTime', { zone: cfg.zone }) };
244      }
245      optedOut = false;
246      enabled = true;
247      monitor = true;
248      selectedForBackground = true;
249      await registerCheckpoint($);
250      attempts = 0;
251      if (!lastError) {
252        const messages = await $.session.messages();
253        const last = messages.at(-1);
254        if (last?.role === 'assistant' && isLimitError(last.text)) lastError = last.text;
255      }
256      await arm($, lastError, true, override);
257    } else if (args === 'on') {
258      optedOut = false;
259      enabled = true;
260      monitor = true;
261      selectedForBackground = true;
262      await registerCheckpoint($);
263      attempts = 0;
264      await save($);
265      await show($, say('on'));
266    } else if (args === 'reserve' || args === 'reserve off') {
267      if (!cfg.reserveFor) return { text: say('reserveNone') };
268      reserved = args === 'reserve';
269      if (reserved) pauseAfterTurn = false;
270      await save($);
271      await show($, reserved ? say('reserveOn', { name: cfg.reserveFor }) : say('reserveOffMark'));
272    } else if (args === 'checkpoint') {
273      return { text: checkpoint ? JSON.stringify(checkpoint, null, 2) : say('noCheckpoint') };
274    } else if (args && args !== 'status') {
275      return { text: say('commands') };
276    }
277    const surfaces = await $.session.surfaces();
278    return { text: say('status', { version: VERSION, id: sessionId, surfaces: surfaces.join(', ') || say('headless'), state: say(enabled ? 'enabled' : 'disabled'), reserved: reserved && cfg.reserveFor ? say('reservedMark', { name: cfg.reserveFor }) : '', status: status || say('ready') }) };
279
280}
281
282async function measureBudget($, limits) {
283  if (!interactive || !enabled || !monitor) return;
284  const now = await $.clock.now();
285  const previous = latestBudget;
286  paceSamples = trackPace(paceSamples, limits, now);
287  latestBudget = budgetWindow(limits, now, cfg);
288  if (!latestBudget) { pauseAfterTurn = false; if (previous) await save($); return; }
289  // Retain only active window keys. A later window can warn again.
290  const keys = new Set((limits ?? []).map(w => `${w.kind}:${w.resetsAt}`));
291  warned = Object.fromEntries(Object.entries(warned).filter(([k]) => keys.has(k)));
292  if (JSON.stringify(previous) !== JSON.stringify(latestBudget)) await save($);
293}
294
295export function register(on, options = {}) {
296  cfg = settings(options);
297  on('session.start', async ($, e, next) => {
298    // Desktop can start its SDK host before attaching the graphical client.
299    interactive = e.surface === 'desktop' || (e.isInteractive && e.surface === 'terminal');
300    const result = await next(e);
301    if (!interactive) interactive = (await $.session.surfaces()).includes('desktop');
302    await $.command.register({ name: 'limit-resume', description: say('commandDescription'), argumentHint: 'on | arm | at HH:MM | off | reserve | status | checkpoint', immediate: true });
303    await ensureSession($);
304    if (enabled && monitor && interactive) await registerCheckpoint($);
305    return result;
306  });
307
308  on('session.attach', { surface: 'desktop' }, async ($, e, next) => {
309    const result = await next(e);
310    interactive = true;
311    await ensureSession($);
312    return result;
313  });
314
315  on('classic.StopFailure', async ($, e, next) => {
316    const result = await next(e);
317    if (!e.agent_id && interactive) {
318      working = false;
319      const text = e.last_assistant_message ?? '';
320      if (['rate_limit', 'billing_error'].includes(e.error) && isLimitError(text)) {
321        lastError = text;
322        await arm($, text);
323      }
324    }
325    return result;
326  });
327
328  on('session.append', async ($, e, next) => {
329    const result = await next(e);
330    if (!e.agentId && e.origin.kind === 'model' && e.origin.model === '<synthetic>') {
331      const text = e.message.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
332      if (isLimitError(text)) lastError = text;
333    }
334    return result;
335  });
336
337  on('turn.complete', async ($, e, next) => {
338    const result = await next(e);
339    if (e.agentId || !interactive) return result;
340    working = false;
341    ownSubmission = false;
342    if (e.reason === 'error' && !e.isAborted) {
343      await arm($, isLimitError(e.answer) ? e.answer : lastError);
344    } else {
345      lastError = '';
346      if (pauseAfterTurn && enabled && monitor && e.reason === 'answer' && latestBudget) {
347        pauseAfterTurn = false;
348        await arm($, '', true, latestBudget.resetAt, true);
349        return result;
350      }
351      await cancel($, null);
352      status = say(enabled ? 'ready' : 'offStatus');
353    }
354    return result;
355  });
356
357  on('prompt.submit', async ($, e, next) => {
358    // Desktop may send a custom slash command as client-composed text.
359    // Only a standalone command from the human-facing transport is local.
360    const local = /^\/limit-resume(?:[ \t]+([^\r\n]*))?[ \t]*$/.exec(e.text);
361    if (local && !e.attachments?.length && ['composer', 'sdk', 'bridge'].includes(e.origin.kind)) {
362      interactive = true;
363      const reply = await control($, local[1] ?? 'status');
364      return { drop: reply.text };
365    }
366    if (interactive && !(e.origin.kind === 'plugin' && e.origin.name === 'limit-resume')) {
367      ownSubmission = false;
368      pauseAfterTurn = false;
369      lastError = '';
370      await cancel($, pending ? say('otherRequest') : null);
371    }
372    if (interactive && enabled && monitor) {
373      // The budget line is advisory: a failed read must never block the owner's message.
374      let line = null;
375      try {
376        await ensureSession($);
377        const [usage, now] = [await $.session.usage(), await $.clock.now()];
378        paceSamples = trackPace(paceSamples, usage.rateLimits, now);
379        line = budgetLine(usage.rateLimits, now, paceSamples, cfg);
380      } catch {}
381      if (line) return next({ ...e, context: [...(e.context ?? []), line] });
382    }
383    return next(e);
384  });
385
386  on('turn.start', async ($, e, next) => {
387    if (!e.agentId && interactive) {
388      await ensureSession($);
389      const ours = ownSubmission;
390      // Any turn that is not our continuation is new work: a checkpoint saved
391      // before it describes an older task.
392      if (!ours) ownerTurn += 1;
393      cancelTimer();
394      if (!ours) attempts = 0;
395      working = true;
396      lastError = '';
397      await save($);
398    }
399    return next(e);
400  });
401
402  on('prompt.edit', async ($, e, next) => {
403    if ((pending || ownSubmission) && (e.text?.trim() || e.inputText?.trim())) {
404      ownSubmission = false;
405      await cancel($, say('editing'));
406    }
407    return next(e);
408  });
409
410  on('session.end', async ($, e, next) => {
411    const waiting = pending;
412    cancelTimer();
413    if (e.reason === 'other' && waiting) pending = waiting;
414    await save($);
415    sessionId = null;
416    working = false;
417    return next(e);
418  });
419
420  on('command.run', { command: 'limit-resume' }, ($, e) => control($, e.args));
421
422  on('session.measure', async ($, e, next) => {
423    const result = await next(e);
424    await measureBudget($, e.rateLimits);
425    return result;
426  });
427
428  on('tool.call', async ($, e, next) => {
429    if (e.tool === CHECKPOINT_TOOL) {
430      if (e.agentId || !interactive || !enabled || !monitor) return { deny: say('denyOff') };
431      const summary = cleanCheckpoint(e);
432      if (!CONTINUATIONS.includes(e.continuation)) return { deny: say('denyContinuation') };
433      if (typeof e.verifyFirst !== 'boolean') return { deny: say('denyVerify') };
434      if (!summary) return { deny: say('denyFields') };
435      const now = await $.clock.now();
436      const usage = await $.session.usage();
437      await measureBudget($, usage.rateLimits);
438      checkpoint = { sessionId, turn: ownerTurn, savedAt: new Date(now).toISOString(), ...summary, continuation: e.continuation, verifyFirst: e.verifyFirst };
439      pauseAfterTurn = e.continuation === 'auto' && latestBudget?.level === RESERVE && !reserved;
440      await save($);
441      await show($, say(pauseAfterTurn ? 'savedPause' : 'saved'));
442      // A registered tool answers with text: the engine refuses an object as its output.
443      const result = say('savedResult', { at: checkpoint.savedAt });
444      return pauseAfterTurn ? { result, context: [say('savedContext')] } : { result };
445    }
446    const result = await next(e);
447    if (!e.agentId && interactive && enabled && monitor && !result.deny) {
448      const usage = await $.session.usage();
449      await measureBudget($, usage.rateLimits);
450      if (latestBudget && (warned[latestBudget.key] ?? 0) < latestBudget.level) {
451        warned[latestBudget.key] = latestBudget.level;
452        await save($);
453        await show($, latestBudget.level === RESERVE && !reserved ? say('warnReserve', { pct: latestBudget.percent, name: cfg.reserveFor }) : say('warn', { pct: latestBudget.percent }));
454        return { ...result, context: [...(result.context ?? []), checkpointNotice(latestBudget, reserved, cfg)] };
455      }
456    }
457    return result;
458  });
459
460  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
461    if (e.surface === 'desktop') { interactive = true; await ensureSession($); }
462    const base = await next(e);
463    // A band in every session would be noise: show it while waiting or from 70%.
464    if ((!pending && !(enabled && latestBudget)) || e.props.hasSurvey) return base;
465    const { Box, Text, Button } = $.ui.resolve(e);
466    return h(Box, { flexDirection: 'column' }, base,
467      h(Box, { flexDirection: 'row', columnGap: 1 },
468        h(Text, { color: 'warning' }, pending ? say('band', { time: timeLabel(pending.deadline) }) : `limit-resume: ${status || say('ready')}`),
469        h(Button, { key: 'limit-resume-cancel', label: say('cancel'), onPress: async () => { optedOut = true; enabled = false; monitor = false; selectedForBackground = false; pauseAfterTurn = false; await cancel($, say('off')); } })));
470  });
471}
472
hooks/budget.js 111 lines
1import { DEFAULTS, dayLabel, t, timeLabel } from './config.js';
2
3export const CHECKPOINT_TOOL = 'mcp__limit-resume__checkpoint';
4export const CHECKPOINT_FIELDS = ['goal', 'completed', 'remaining', 'nextStep', 'checks', 'constraints'];
5export const CONTINUATIONS = ['auto', 'owner', 'done'];
6
7// Work at full strength and judge by the remaining budget and the time to the
8// reset. Hard steps only far from the reset: at 90% save state; with a reserve
9// configured, from 97% of the five-hour window or 99% of the week only the
10// marked session spends the rest. Within NEAR_RESET_MS of the reset economy is lifted.
11export const RESERVE = 97;
12const SAVE_AT = 90;
13const RESERVE_AT = { five_hour: 97, seven_day: 99 };
14export const NEAR_RESET_MS = 15 * 60_000;
15const PACE_SPAN_MS = 60 * 60_000;
16const PACE_MIN_MS = 5 * 60_000;
17
18function windowLevel(w, now, cfg) {
19  // The reserve holds up to the reset; only economy is lifted near it.
20  if (cfg.reserveFor && w.percentUsed >= RESERVE_AT[w.kind]) return RESERVE;
21  if (Date.parse(w.resetsAt) - now <= NEAR_RESET_MS) return 0;
22  return w.percentUsed >= SAVE_AT ? SAVE_AT : 0;
23}
24
25export function budgetWindow(limits, now, cfg = DEFAULTS) {
26  const windows = (limits ?? []).filter(w => w.kind in RESERVE_AT
27    && Number.isFinite(w.percentUsed) && Number.isFinite(Date.parse(w.resetsAt)) && Date.parse(w.resetsAt) > now)
28    .map(w => ({ ...w, level: windowLevel(w, now, cfg) })).filter(w => w.level);
29  if (!windows.length) return null;
30  windows.sort((a, b) => b.level - a.level || b.percentUsed - a.percentUsed || Date.parse(b.resetsAt) - Date.parse(a.resetsAt));
31  const w = windows[0];
32  // Wait for every window that blocks: a five-hour reset does not lift the weekly reserve.
33  return { key: `${w.kind}:${w.resetsAt}`, level: w.level,
34    percent: w.percentUsed, resetAt: Math.max(...windows.filter(x => x.level === w.level).map(x => Date.parse(x.resetsAt))) };
35}
36
37// Samples of the five-hour window for the pace: the last hour of this session.
38export function trackPace(samples, limits, now) {
39  const w = (limits ?? []).find(x => x.kind === 'five_hour' && Number.isFinite(x.percentUsed));
40  if (!w) return samples ?? [];
41  let list = (samples ?? []).filter(x => x.resetsAt === w.resetsAt && now - x.at <= PACE_SPAN_MS && x.pct <= w.percentUsed);
42  const last = list.at(-1);
43  if (!last || last.pct !== w.percentUsed || now - last.at >= 2 * 60_000) list = [...list, { at: now, pct: w.percentUsed, resetsAt: w.resetsAt }];
44  return list.slice(-40);
45}
46
47// Forecast for the five-hour window from the observed pace, or null while unknown.
48export function forecast(samples, limits, now, cfg = DEFAULTS) {
49  const w = (limits ?? []).find(x => x.kind === 'five_hour' && Number.isFinite(x.percentUsed));
50  const list = (samples ?? []).filter(x => w && x.resetsAt === w.resetsAt);
51  if (!w || list.length < 2) return null;
52  const span = list.at(-1).at - list[0].at;
53  if (span < PACE_MIN_MS) return null;
54  const pace = (list.at(-1).pct - list[0].pct) / span;
55  const reset = Date.parse(w.resetsAt);
56  const m = Math.round(span / 60_000);
57  if (pace <= 0) return { short: false, text: t(cfg, 'paceFlat', { m }) };
58  const out = now + (100 - w.percentUsed) / pace;
59  if (out >= reset) return { short: false, text: t(cfg, 'paceEnough', { m, pct: Math.min(100, Math.round(w.percentUsed + pace * (reset - now))) }) };
60  return { short: true, text: t(cfg, 'paceShort', { m, time: timeLabel(out, cfg), left: Math.round((reset - out) / 60_000) }) };
61}
62
63export function savingRule(cfg = DEFAULTS) {
64  return t(cfg, 'rule') + (cfg.hint ? cfg.hint + ' ' : '') + t(cfg, 'ruleNear')
65    + (cfg.reserveFor ? t(cfg, 'ruleReserve', { name: cfg.reserveFor }) : '') + t(cfg, 'ruleTail');
66}
67
68function untilLabel(ms, now, cfg) {
69  const minutes = Math.max(0, Math.round((ms - now) / 60_000));
70  if (minutes < 120) return t(cfg, 'inMinutes', { m: minutes });
71  const hours = Math.floor(minutes / 60);
72  return hours < 48 ? t(cfg, 'inHours', { h: hours, m: minutes % 60 }) : t(cfg, 'inDays', { d: Math.floor(hours / 24), h: hours % 24 });
73}
74
75// One line per owner turn: the agent plans against the remaining budget.
76export function budgetLine(limits, now, samples = [], cfg = DEFAULTS) {
77  const names = t(cfg, 'windows');
78  const windows = (limits ?? []).filter(w => names[w.kind] && Number.isFinite(w.percentUsed));
79  const parts = windows.map(w => {
80    const reset = Date.parse(w.resetsAt ?? '');
81    const when = Number.isFinite(reset) && reset > now
82      ? t(cfg, 'reset', { until: untilLabel(reset, now, cfg), when: (w.kind === 'seven_day' ? dayLabel(reset, cfg) + ' ' : '') + timeLabel(reset, cfg) }) : '';
83    return `${names[w.kind]} ${Math.round(w.percentUsed)}%${when}`;
84  });
85  if (!parts.length) return null;
86  const f = forecast(samples, limits, now, cfg);
87  // The rule costs tokens: only when the budget may matter.
88  const relevant = f?.short || windows.some(w => w.percentUsed >= 70);
89  return `${t(cfg, 'lineHead')}${parts.join('; ')}${f ? '; ' + f.text : ''}.`
90    + (relevant ? ' ' + savingRule(cfg) : '');
91}
92
93export function checkpointNotice(w, reserved = false, cfg = DEFAULTS) {
94  const reserve = w.level === RESERVE && !reserved;
95  return t(cfg, 'noticeHead', { pct: w.percent }) + t(cfg, 'noticeCall', { tool: CHECKPOINT_TOOL })
96    + (reserve ? t(cfg, 'noticeReserve', { name: cfg.reserveFor }) : t(cfg, 'noticeGoOn'));
97}
98
99export function cleanCheckpoint(input) {
100  const result = {};
101  let size = 0;
102  for (const field of CHECKPOINT_FIELDS) {
103    if (typeof input[field] !== 'string' || !input[field].trim()) return null;
104    const text = input[field].trim();
105    if (text.length > 4000) return null;
106    size += text.length;
107    result[field] = text;
108  }
109  return size <= 12000 ? result : null;
110}
111
hooks/logic.js 54 lines
1import { DEFAULTS, localDate, t, validZone, zonedTimes } from './config.js';
2export { timeLabel } from './config.js';
3
4export const resumeText = (cfg = DEFAULTS) => t(cfg, 'resume');
5export const GRACE_MS = 60_000;
6export const MAX_ATTEMPTS = 3;
7export const MAX_WAIT_MS = 8 * 24 * 60 * 60_000;
8
9export function isLimitError(text) {
10  if (typeof text !== 'string') return false;
11  const clean = text.replace(/^Claude Code returned an error result:\s*/i, '')
12    .replace(/^API Error:?\s*(?:\d{3}\s*)?/i, '').trim();
13  return /^(?:you['’]ve (?:hit|reached) your (?:session|weekly|usage|opus|sonnet).*limit|you['’]re out of (?:usage credits|extra usage)|(?:out of usage credits|session limit reached|weekly limit reached|usage limit reached)|rate limit reached)/i.test(clean);
14}
15
16export function exhaustedReset(limits) {
17  const resets = (limits ?? []).filter(r =>
18    ['five_hour', 'seven_day'].includes(r.kind) && r.percentUsed >= 100
19  ).map(r => Date.parse(r.resetsAt ?? '')).filter(Number.isFinite);
20  return resets.length ? Math.max(...resets) : null;
21}
22
23// Only an engine error, or an explicit /limit-resume command, reaches this parser.
24// Structured ISO reset times take priority. Avoid guessing dates for weekly limits.
25export function clockTime(text, now, zone = DEFAULTS.zone) {
26  const match = text.trim().match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
27  if (!match) return null;
28  let hours = Number(match[1]);
29  const minutes = Number(match[2] ?? 0);
30  if (minutes > 59 || (match[3] ? hours < 1 || hours > 12 : hours > 23)) return null;
31  if (match[3]) hours = hours % 12 + (match[3].toLowerCase() === 'pm' ? 12 : 0);
32  const today = localDate(now, zone);
33  const next = new Date(Date.UTC(today.year, today.month - 1, today.day + 1));
34  const moments = [...zonedTimes(zone, today.year, today.month, today.day, hours, minutes),
35    ...zonedTimes(zone, next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), hours, minutes)];
36  return moments.find(ms => ms >= now - 5 * 60_000) ?? null;
37}
38
39// The engine names the zone of its reset time; without one the configured zone is assumed.
40export function textReset(text, now, zone = DEFAULTS.zone) {
41  if (!isLimitError(text) || /weekly|seven.day/i.test(text)) return null;
42  const tokens = [...text.matchAll(/\(([^()\s]+)\)/g)].map(m => m[1]);
43  const named = tokens.find(validZone);
44  // A zone the runtime does not know: better no time than a wrong one.
45  if (!named && tokens.some(x => x.includes('/'))) return null;
46  const match = text.match(/(?:resets?|reset at)\s+(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm)?)(?!\w)/i);
47  return match ? clockTime(match[1], now, named ?? zone) : null;
48}
49
50export function nextDeadline(resetAt, now, attempts) {
51  if (!Number.isFinite(resetAt) || resetAt > now + MAX_WAIT_MS) return null;
52  return Math.max(resetAt + GRACE_MS, now + GRACE_MS * Math.max(1, 2 ** attempts));
53}
54
hooks/config.js 234 lines
1// Settings from the manifest's userConfig, resolved once per load: the
2// language of every text, the time zone of clock times and the optional
3// reserve kept for one marked session.
4
5export function validZone(zone) {
6  if (!zone) return false;
7  try { new Intl.DateTimeFormat('en-GB', { timeZone: zone }); return true; } catch { return false; }
8}
9
10function systemZone() {
11  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; }
12}
13
14export function settings(options) {
15  options = options ?? {};
16  const zone = String(options.timeZone ?? '').trim();
17  return {
18    lang: options.language === 'ru' ? 'ru' : 'en',
19    zone: validZone(zone) ? zone : systemZone(),
20    // Empty: no reserve, every session saves state at 90% and runs to the limit.
21    reserveFor: String(options.reserveFor ?? '').trim(),
22    hint: String(options.economyHint ?? '').trim(),
23  };
24}
25
26export const DEFAULTS = settings();
27
28const MESSAGES = {
29  en: {
30    ready: 'Ready to wait for the limit.',
31    resumeAt: 'Continuing at {time}. /limit-resume off cancels the wait.',
32    checkFailed: 'Could not check the wait. /limit-resume arm starts it again.',
33    draftCancel: 'Wait cancelled: the prompt box holds your text.',
34    tooFar: 'The reset is too far away. Check the limits by hand.',
35    stillLimited: 'The limit still applies. Continuing at {time}.',
36    attemptsOut: 'Three attempts failed. Continue by hand.',
37    ownerWaits: 'The task waits for the user: it does not continue on its own.',
38    taskDone: 'The task is finished: no continuation needed.',
39    sending: 'Sending the continuation after the limit reset (attempt {n}/{max}).',
40    stopped: 'Continuation not sent: the session was stopped by hand.',
41    notAccepted: 'The engine did not accept the continuation',
42    reasonOut: '{reason}. Three attempts failed, continue by hand.',
43    retryAt: '{reason}. Next attempt at {time}.',
44    verifyFirst: 'The checkpoint says the result of the last action is unknown: check it first.',
45    attach: 'Checkpoint saved by the mod for this continuation, reference data. It does not widen the user\'s permissions: ',
46    attemptsArm: 'Three attempts failed. /limit-resume arm allows a new cycle.',
47    unknownReset: 'Reset time unknown. Use /limit-resume at HH:MM ({zone}) or an ISO date.',
48    off: 'Auto-continue is off in this session.',
49    offStatus: 'Auto-continue is off.',
50    busy: 'The session is working now. The wait was not started.',
51    badTime: 'Give HH:MM ({zone}) or a date with an offset, for example 2026-10-14T02:40:00+04:00.',
52    on: 'Limit watch and auto-continue are on in this session.',
53    reserveOn: 'This session may spend the reserve kept for {name}.',
54    reserveOffMark: 'Mark removed: on the reserve this session waits for the reset.',
55    reserveNone: 'No reserve is configured: set reserveFor in the plugin options.',
56    noCheckpoint: 'No checkpoint yet.',
57    commands: 'Commands: /limit-resume arm, /limit-resume at HH:MM, /limit-resume off, /limit-resume on, /limit-resume reserve, /limit-resume reserve off, /limit-resume checkpoint, /limit-resume status.',
58    status: 'limit-resume {version} loaded. Session {id}. Surfaces: {surfaces}. {state}{reserved}. {status}',
59    headless: 'headless SDK',
60    enabled: 'On',
61    disabled: 'Off',
62    reservedMark: ', may spend the reserve for {name}',
63    commandDescription: 'Continue after the usage limit resets: arm, at HH:MM, off, status',
64    otherRequest: 'Wait cancelled: another request arrived.',
65    editing: 'Wait cancelled: you are editing the message.',
66    denyOff: 'Saving state is off or was called from a subagent.',
67    denyContinuation: 'Give continuation: auto, owner or done.',
68    denyVerify: 'Give verifyFirst: whether the last action must be checked first.',
69    denyFields: 'Six non-empty fields are needed, each up to 4000 characters, 12000 in all.',
70    savedPause: 'State saved. After this turn ends the session waits for the limit reset.',
71    saved: 'State saved for the continuation.',
72    savedResult: 'State saved {at}.',
73    savedContext: 'A fresh checkpoint is saved. Finish the current turn; do not start a new stage. The mod continues this same session after the limit resets.',
74    warnReserve: '{pct}% of the limit used. The rest is kept for {name}; the session saves its state and waits for the reset.',
75    warn: '{pct}% of the limit used. Preparing for exhaustion, work goes on.',
76    band: 'Continuing at {time}',
77    cancel: 'Cancel',
78    toolDescription: 'Save a short state of the current main session for continuing after the usage limit. No secrets and no full command output. Publishes nothing. Only while limit-resume is on.',
79    toolContinuation: 'auto: work remains and can go on without the user; owner: work waits for the user\'s decision or answer and does not continue on its own; done: the task is finished.',
80    toolVerify: 'true if the result of the last action is unknown or it may have run partly: check it first when continuing.',
81    resume: 'The usage limit has reset. Continue the interrupted task from where it stopped, keeping the earlier constraints and approvals. '
82      + 'First check how the last action before the stop ended (a command, a write, a send, a started process) and do not repeat what is already done. '
83      + 'If the task is already finished or waits for the user\'s decision, do not continue it on your own and do not start a new one.',
84    windows: { five_hour: '5 hours', seven_day: 'week' },
85    lineHead: 'limit-resume mod, latest estimate of the subscription limit: ',
86    reset: ', resets {until} ({when})',
87    inMinutes: 'in {m} min',
88    inHours: 'in {h} h {m} min',
89    inDays: 'in {d} d {h} h',
90    paceFlat: 'over the last {m} min usage is not growing (data may lag)',
91    paceEnough: 'if the pace of the last {m} min holds, it lasts until the reset, at about {pct}% by then',
92    paceShort: 'if the pace of the last {m} min holds, the limit runs out around {time}, about {left} min before the reset',
93    rule: 'Work at full strength and judge by what remains and the time to the reset; the forecast is an estimate, other sessions change the pace. '
94      + 'If the rest may not last until the reset: pick the next finished, checkable stage that is safe to interrupt, and keep the checkpoint fresh. '
95      + 'A result that can wait for the reset is better finished in full after it: the mod continues the session on its own. Cut scope only if the result is needed before the reset, and never skip required checks. ',
96    ruleNear: 'Within 15 minutes of a window\'s reset its economy can be ignored. ',
97    ruleReserve: 'From 97% of the five-hour window or 99% of the week the rest is kept for {name} until the reset: an ordinary session saves its checkpoint, ends its turn and waits, the mod continues it on its own. ',
98    ruleTail: 'A new session does not reset the shared limit.',
99    noticeHead: 'limit-resume mod: {pct}% of the subscription limit used. ',
100    noticeCall: 'Call {tool} with a short record: goal, done, remaining, next step, checks, constraints in force; '
101      + 'continuation: auto if work remains and can go on without the user; owner if it waits for the user\'s decision or answer; done if the task is finished; '
102      + 'verifyFirst: true if the result of the last action is unknown or it may have run partly. '
103      + 'Do not include keys, passwords, personal data or full command output. Do not commit, send or change running services for the sake of saving. ',
104    noticeReserve: 'The rest is kept for {name}. Bring the work to a safe point, save a fresh checkpoint and end the current turn. The mod continues this same session after the reset. A new session does not reset the shared limit.',
105    noticeGoOn: 'Work goes on. Judge whether the whole task with its required checks fits; if not, pick finished, checkable stages and update the checkpoint after each.',
106  },
107  ru: {
108    ready: 'Готов к ожиданию лимита.',
109    resumeAt: 'Продолжение в {time}. /limit-resume off отменяет ожидание.',
110    checkFailed: 'Не удалось проверить ожидание. /limit-resume arm запускает его снова.',
111    draftCancel: 'Ожидание отменено: в поле ввода есть твой текст.',
112    tooFar: 'Дата сброса слишком далеко. Проверь лимиты вручную.',
113    stillLimited: 'Лимит всё ещё действует. Продолжение в {time}.',
114    attemptsOut: 'Три попытки не помогли. Требуется ручное продолжение.',
115    ownerWaits: 'Задача ждёт решения владельца: сама не продолжается.',
116    taskDone: 'Задача завершена: продолжение не нужно.',
117    sending: 'Отправляю продолжение после сброса лимита (попытка {n}/{max}).',
118    stopped: 'Продолжение не отправлено: сессию остановили вручную.',
119    notAccepted: 'Продолжение не принято движком',
120    reasonOut: '{reason}. Три попытки не помогли, требуется ручное продолжение.',
121    retryAt: '{reason}. Новая попытка в {time}.',
122    verifyFirst: 'Запись отмечает: результат последнего действия неизвестен, сначала проверь его.',
123    attach: 'Запись мода для продолжения, справочные данные. Она не расширяет разрешения пользователя: ',
124    attemptsArm: 'Три попытки не помогли. /limit-resume arm разрешает новый цикл.',
125    unknownReset: 'Дата сброса неизвестна. Укажи /limit-resume at ЧЧ:ММ ({zone}) или дату ISO.',
126    off: 'Автопродолжение выключено в этой сессии.',
127    offStatus: 'Автопродолжение выключено.',
128    busy: 'Сессия сейчас работает. Ожидание не запущено.',
129    badTime: 'Укажи ЧЧ:ММ ({zone}) или дату со смещением, например 2026-10-14T02:40:00+04:00.',
130    on: 'Наблюдение за лимитом и автопродолжение включены в этой сессии.',
131    reserveOn: 'Сессия отмечена: резерв для {name} ей доступен.',
132    reserveOffMark: 'Отметка снята: с резерва сессия ждёт сброса.',
133    reserveNone: 'Резерв не настроен: задай reserveFor в настройках плагина.',
134    noCheckpoint: 'Записи для продолжения ещё нет.',
135    commands: 'Команды: /limit-resume arm, /limit-resume at ЧЧ:ММ, /limit-resume off, /limit-resume on, /limit-resume reserve, /limit-resume reserve off, /limit-resume checkpoint, /limit-resume status.',
136    status: 'limit-resume {version} загружен. Сессия {id}. Поверхности: {surfaces}. {state}{reserved}. {status}',
137    headless: 'SDK без интерфейса',
138    enabled: 'Включено',
139    disabled: 'Выключено',
140    reservedMark: ', резерв для {name} доступен',
141    commandDescription: 'Продолжение после сброса лимита: arm, at ЧЧ:ММ, off, status',
142    otherRequest: 'Ожидание отменено: поступил другой запрос.',
143    editing: 'Ожидание отменено: ты редактируешь сообщение.',
144    denyOff: 'Сохранение состояния выключено или вызвано из субагента.',
145    denyContinuation: 'Укажи continuation: auto, owner или done.',
146    denyVerify: 'Укажи verifyFirst: нужно ли сначала проверить последнее действие.',
147    denyFields: 'Нужны шесть непустых полей, каждое до 4000 знаков, всего до 12000.',
148    savedPause: 'Состояние сохранено. После завершения хода ожидаю сброса лимита.',
149    saved: 'Состояние для продолжения сохранено.',
150    savedResult: 'Состояние сохранено {at}.',
151    savedContext: 'Свежая запись сохранена. Заверши текущий ход; не начинай новый этап. Мод продолжит эту же сессию после сброса лимита.',
152    warnReserve: 'Использовано {pct}% лимита. Остаток для {name}, сессия сохранит состояние и подождёт сброса.',
153    warn: 'Использовано {pct}% лимита. Подготовка к исчерпанию, работа продолжается.',
154    band: 'Продолжение в {time}',
155    cancel: 'Отменить',
156    toolDescription: 'Сохранить краткое состояние текущей основной сессии для продолжения перед лимитом. Без секретов и полных выводов команд. Ничего не публикует. Только при включённом limit-resume.',
157    toolContinuation: 'auto: работа осталась, продолжать можно без владельца; owner: работа ждёт решения или ответа владельца, сама не продолжается; done: задача завершена.',
158    toolVerify: 'true, если результат последнего действия неизвестен или оно могло выполниться частично: при продолжении сначала проверить.',
159    resume: 'Лимит использования сбросился. Продолжи прерванную задачу с места остановки, соблюдая прежние ограничения и согласования. '
160      + 'Сначала проверь, чем кончилось последнее действие до остановки (команда, запись, отправка, запущенный процесс), и не повторяй уже сделанного. '
161      + 'Если задача уже завершена или ждёт решения владельца, не продолжай её сам и не начинай новую.',
162    windows: { five_hour: '5 часов', seven_day: 'неделя' },
163    lineHead: 'Мод limit-resume, последняя оценка лимита подписки: ',
164    reset: ', сброс {until} ({when})',
165    inMinutes: 'через {m} мин',
166    inHours: 'через {h} ч {m} мин',
167    inDays: 'через {d} дн {h} ч',
168    paceFlat: 'по данным последних {m} мин расход не растёт (данные могут отставать)',
169    paceEnough: 'если темп последних {m} мин сохранится, хватит до сброса, к нему будет около {pct}%',
170    paceShort: 'если темп последних {m} мин сохранится, лимит кончится около {time}, примерно за {left} мин до сброса',
171    rule: 'Работай полноценно и суди по остатку и времени до сброса; прогноз это оценка, соседние сессии меняют темп. '
172      + 'Если остатка может не хватить до сброса: выбирай следующий законченный и проверяемый этап, который можно безопасно прервать, и держи запись checkpoint свежей. '
173      + 'Результат, который может подождать сброса, лучше доделать полностью после него: мод продолжит сессию сам. Сокращай объём, только если результат нужен раньше сброса, и обязательные проверки не пропускай. ',
174    ruleNear: 'За 15 минут до сброса окна экономию по нему можно не соблюдать. ',
175    ruleReserve: 'С 97% пятичасового или 99% недельного остаток для {name} до самого сброса: обычная сессия сохраняет запись, завершает ход и ждёт, мод сам её продолжит. ',
176    ruleTail: 'Новая сессия общий лимит не сбрасывает.',
177    noticeHead: 'Мод limit-resume: использовано {pct}% лимита подписки. ',
178    noticeCall: 'Вызови {tool} с краткой записью: цель, сделанное, оставшееся, следующий шаг, проверки, действующие ограничения; '
179      + 'continuation: auto, если работа осталась и её можно продолжать без владельца; owner, если она ждёт его решения или ответа; done, если задача завершена; '
180      + 'verifyFirst: true, если результат последнего действия неизвестен или оно могло выполниться частично. '
181      + 'Не включай ключи, пароли, личные данные или полный вывод команд. Не делай ради сохранения коммит, отправку или изменение рабочих сервисов. ',
182    noticeReserve: 'Остаток зарезервирован для {name}. Доведи начатое до безопасной точки, сохрани свежую запись и заверши текущий ход. Мод продолжит эту же сессию после сброса. Новая сессия общий лимит не обнулит.',
183    noticeGoOn: 'Работа продолжается. Оцени, успеешь ли закончить задачу целиком с обязательными проверками; если нет, выбирай законченные проверяемые этапы и обновляй запись после каждого.',
184  },
185};
186
187export function t(cfg, key, vars = {}) {
188  const text = MESSAGES[cfg.lang][key];
189  return typeof text === 'string' ? text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : text;
190}
191
192// Clock times in the configured zone.
193function parts(ms, zone) {
194  const f = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
195  const o = Object.fromEntries(f.formatToParts(ms).map(p => [p.type, Number(p.value)]));
196  return { year: o.year, month: o.month, day: o.day, hour: o.hour, minute: o.minute };
197}
198
199// The zone's offset from UTC at that moment, in milliseconds.
200function offset(ms, zone) {
201  const p = parts(ms, zone);
202  return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) - Math.floor(ms / 60_000) * 60_000;
203}
204
205// The moments a wall-clock time of that zone names, earliest first: two in the
206// hour a DST change repeats; for a time the change skips, the moment after the gap.
207export function zonedTimes(zone, year, month, day, hour, minute) {
208  const wall = Date.UTC(year, month - 1, day, hour, minute);
209  const offsets = [...new Set([-86_400_000, 0, 86_400_000].map(d => offset(wall + d, zone)))];
210  const hits = [...new Set(offsets.map(o => wall - o))].filter(ms => {
211    const p = parts(ms, zone);
212    return p.year === year && p.month === month && p.day === day && p.hour === hour && p.minute === minute;
213  });
214  return hits.length ? hits.sort((a, b) => a - b) : [Math.max(...offsets.map(o => wall - o))];
215}
216
217export function localDate(ms, zone) {
218  return parts(ms, zone);
219}
220
221function city(zone) {
222  return zone.split('/').pop().replace(/_/g, ' ');
223}
224
225export function timeLabel(ms, cfg = DEFAULTS) {
226  const p = parts(ms, cfg.zone);
227  return `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')} ${city(cfg.zone)}`;
228}
229
230export function dayLabel(ms, cfg = DEFAULTS) {
231  const p = parts(ms, cfg.zone);
232  return `${String(p.day).padStart(2, '0')}.${String(p.month).padStart(2, '0')}`;
233}
234