Resume an interrupted session after its usage limit resets

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.
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.
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.owner or done is not continued./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.
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:
Using the mod together with the built-in setting has not been tested; turn one of them off.
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": "" }
}
}
}
| Option | Default | Meaning |
|---|---|---|
language | en | en or ru: the mod's messages and its notes to Claude |
timeZone | system zone | IANA zone for clock times, such as Europe/Berlin |
reserveFor | empty | Keep the last part of the limit for marked sessions (see below); empty turns it off |
economyHint | empty | Your own advice added to the spending rule, such as "hand scouting to a cheaper model" |
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.
| Command | Effect |
|---|---|
/limit-resume status | Version, session and state |
/limit-resume on / off | Turn the mod on or off in this session; the choice is kept |
/limit-resume arm | Wait for the reset in a session that has already stopped |
/limit-resume at 02:40 | Wait until that time in your zone, or give an ISO date with an offset |
/limit-resume checkpoint | Show the latest checkpoint |
/limit-resume reserve / reserve off | Let this session spend the reserve, or stop that |
These commands are answered by the mod itself, with no model call.
claude plugin validate --strict .
claude plugin test .
MIT License.
hooks/register.js 472 lines1import { 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}
472hooks/budget.js 111 lines1import { 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}
111hooks/logic.js 54 lines1import { 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}
54hooks/config.js 234 lines1// 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