Stops work at a clean point before the 5-hour (or 7-day) usage limit, keeps the prompt cache warm while waiting, and continues the work after the reset.

Long work in Claude Code shouldn't hit the usage limit mid-step. last-call has the model stop at a clean point and write down where it is. It then waits for the window to reset, keeping the prompt cache warm when that is cheaper than reloading it, and continues the work afterwards.
Terminal only.
coldResumeDefault setting applies after 10 minutes (compact by default).Notifications fire on wrap-up, stop, pause, resume, and keep-alive problems.
It continues on its own. After the reset the work resumes with nobody at the terminal, under the session's permission mode. That is the point of the mod. Turn autoResume off if you want to give the go-ahead yourself.
| Command | What it does |
|---|---|
/last-call | Status: fills, burn rate, thresholds, pause and keep-alive state |
/last-call go | Resume now (before the reset, this also turns the guard off until the reset) |
/last-call override | Turn the guard off until the reset |
/last-call rearm | Turn it back on |
/last-call ping | Send one keep-alive now and show how much the cache served |
go, override, rearm and ping only run when you type them, so the model can't lift its own stop.
In /config, or under pluginConfigs["last-call"].options in settings.json. In /config every setting is a toggle or a list you pick from with the keyboard, with the default selected until you choose another value; only the handoff path is typed.
| Setting | Choices | Default | |
|---|---|---|---|
autoResume | on / off | on | Off: wait for /last-call go or your next prompt |
wrapAtPercent / stopAtPercent | 70 80 85 90 95 / 85 90 95 98 100 | 90 / 95 | 5-hour wrap-up and stop |
sevenDayWrapAtPercent / sevenDayStopPercent | same as 5-hour | 90 / 95 | 7-day wrap-up and stop |
wrapLeadMinutes | 0 5 10 15 20 30 | 10 | Also wrap up when the stop is projected this close |
keepAlive | on / off | on | Keep the cache warm while paused |
keepAliveMinutes | 15 30 45 50 55 | 55 | Minutes between pings |
keepAliveSessions | all one | all | one: only one session on this machine pings at a time |
handoffPath | any path | .claude/last-call/handoff.md | Must stay inside the project |
handoffGitIgnored | on / off | on | Adds the note to .git/info/exclude (no tracked file changes) |
coldResumeDefault | compact full wait | compact | When nobody answers |
coldResumeTimeoutMinutes | 1 5 10 15 30 60 | 10 | How long the cold-resume question waits |
A wrap-up picked above its stop is held to the stop.
/plugin install last-call --marketplace Sunkanxx/Modshooks/last-call.jsx 526 lines1// last-call: stops work at a clean point before the usage limit, keeps the
2// prompt cache warm while it waits for the reset, and continues afterwards.
3//
4// The engine reads on(...) and $.noun.method(...) from this source: they stay
5// spelled out, and every function that takes $ lives at the top level. A hook
6// that fails lets the work through: the mod never blocks work on its own error.
7
8import { atom, read, update } from "claude-code";
9import { configFrom, DEFAULTS } from "./lib/config.mjs";
10import { KINDS, windowOf, nextWindow } from "./lib/readings.mjs";
11import { initialState, nextPhase, rolledKinds, resumeTime } from "./lib/phases.mjs";
12import { isPingDue, cacheWasWarm, isWarmAt, keepAliveDecision, lockIsOurs, MIN_PING_GAP_MS } from "./lib/keepalive.mjs";
13import {
14 wrapNote, stopNote, denyText, pausedDenyText, resumePrompt, statusText, report, windowLabel, pct, fmtWhen, fmtDuration,
15 PING_PROMPT, ASK_SEND_OFF, ASK_SEND_ONCE, ASK_DONT, pausedQuestion,
16 COLD_COMPACT, COLD_FULL, COLD_OPTIONS, coldQuestion, compactInstructions,
17} from "./lib/texts.mjs";
18import { normalize, targetFrom, isTarget, isInside, parentsOf, excludeLine, hasLine } from "./lib/handoff.mjs";
19
20const guard = atom({ plugin: "last-call", key: "guard" }, initialState());
21
22const COMMAND = "last-call";
23const TICK_MS = 60_000;
24// Tool calls refused after the stop before the mod ends the turn itself.
25const MAX_DENIALS = 4;
26const WRITE_TOOLS = new Set(["Write", "Edit"]);
27const USER_ORIGINS = new Set(["composer", "bridge"]);
28const DIALOG = "last-call-ask";
29// $.store keys, shared by every session on this machine.
30const SHORT_LIVED_KEY = "cacheShortLived";
31const LOCK_KEY = "pingLock";
32const MINUTE = 60_000;
33
34// Set by register(); later hooks read it.
35let cfg = configFrom();
36let tick = null;
37// The running main-loop turn, so a stop can end it.
38let turnId = null;
39// One keep-alive at a time; the open cold-resume dialog, if any.
40let pinging = false;
41let dialog = null;
42
43export const register = (on, options) => {
44 cfg = configFrom(options);
45
46 on("session.start", async ($, e, next) => {
47 await $.command.register({
48 name: COMMAND,
49 description: "Usage limit guard: status, go (resume now), override (guard off until the reset), rearm, ping (test the keep-alive)",
50 argumentHint: "[go|override|rearm|ping]",
51 });
52 const target = await resolveTarget($);
53 // A reload drops an open dialog with the old module: let the tick ask again.
54 await update($, guard, (s) => ({ ...s, cwd: e.cwd, handoffTarget: target, resumeStarted: s.phase === "paused" ? false : s.resumeStarted }));
55 // One tick carries every timed step, so a hot reload re-arms by itself.
56 tick?.cancel();
57 tick = $.clock.every(TICK_MS, () => void onTick($));
58 await refreshStatus($);
59 return next(e);
60 });
61
62 on("session.measure", async ($, e, next) => {
63 if (e.changed.includes("rateLimits") || e.changed.includes("context")) await onReading($, e);
64 return next(e);
65 });
66
67 on("turn.start", ($, e, next) => {
68 turnId = e.turnId;
69 return next(e);
70 });
71
72 // The stop: while stopping, only writing the handoff note goes through.
73 on("tool.call", async ($, e, next) => {
74 const s = await read($, guard);
75 const isNote = WRITE_TOOLS.has(e.tool) && isTarget(s.handoffTarget, e.file_path);
76 // Checked again at the write: a symlink planted since the start would
77 // otherwise turn the one allowed write into a write elsewhere.
78 const isSafeNote = isNote && s.phase === "stopping" && (await isSafeTarget($, await $.session.root(), s.handoffTarget));
79 // While paused no turn should run: one that starts anyway (a background
80 // task's notification, say) gets no tools, so the reserve stays for pings.
81 // AskUserQuestion stays open: the mod's own question during the pause is one.
82 if (s.phase === "paused" && e.tool !== "AskUserQuestion") return { deny: pausedDenyText(s, await $.clock.now()) };
83 if (s.phase !== "stopping" || (isSafeNote && !s.handoff)) {
84 const ran = await next(e);
85 if (isNote && ran.deny === undefined && ran.isError !== true) await recordHandoff($, s.handoffTarget);
86 return ran;
87 }
88 const after = await update($, guard, (x) => ({ ...x, denials: x.denials + 1 }));
89 if (after.denials >= MAX_DENIALS) void endTurn($);
90 return { deny: denyText(s, s.handoffTarget ?? "(unavailable)") };
91 }).catch(($, e, next) => next(e));
92
93 // The turn that wrapped up has ended: pause until the reset.
94 on("turn.complete", async ($, e, next) => {
95 const done = await next(e);
96 if (e.agentId) return done;
97 turnId = null;
98 const s = await read($, guard);
99 if (s.phase === "wrapping" || s.phase === "stopping" || s.returnToPause) {
100 await onReading($, await $.session.usage());
101 await enterPause($);
102 }
103 return done;
104 });
105
106 // A prompt the person typed during the pause: ask before sending it.
107 on("prompt.submit", async ($, e, next) => {
108 if (!USER_ORIGINS.has(e.origin?.kind ?? "composer")) return next(e);
109 const s = await read($, guard);
110 if (s.phase === "ready") {
111 // Past the reset: the person's prompt is the go-ahead.
112 await update($, guard, (x) => ({ ...x, ...cleared() }));
113 await refreshStatus($);
114 return next(e);
115 }
116 if (s.phase !== "paused") return next(e);
117 let answer = ASK_DONT;
118 try {
119 answer = await $.ui.ask(pausedQuestion(s, await $.clock.now()), { header: "last-call", options: [ASK_SEND_OFF, ASK_SEND_ONCE, ASK_DONT] });
120 } catch {
121 // Dismissed: the prompt is not sent.
122 }
123 if (answer === ASK_SEND_OFF) {
124 const until = resetOf(s) ?? (await $.clock.now()) + 5 * 60 * MINUTE;
125 await update($, guard, (x) => ({ ...x, ...cleared(), overrideUntil: until }));
126 await refreshStatus($);
127 return next(e);
128 }
129 if (answer === ASK_SEND_ONCE) {
130 await update($, guard, (x) => ({ ...x, phase: "normal", returnToPause: true }));
131 await refreshStatus($);
132 return next(e);
133 }
134 // "Don't send", a dismissal, or anything typed under "Other".
135 void $.prompt.fill({ text: e.text }).catch(() => {});
136 return { drop: "last-call: paused for the usage limit; the prompt is back in the box" };
137 }).catch(($, e, next) => next(e));
138
139 // The cold-resume dialog: drawn in a pane the mod opens and closes itself.
140 on("ui.render", { component: "Pane", requestId: DIALOG }, async ($, e, next) => {
141 if (!dialog) return next(e);
142 const { Box, Text, Button } = $.ui.resolve(e);
143 const ask = dialog;
144 return (
145 <Box flexDirection="column">
146 <Text>{ask.question}</Text>
147 {ask.options.map((o, i) => (
148 <Button key={o.key} hotkey={String(i + 1)} variant={i === 0 ? "primary" : undefined} onPress={() => ask.settle(o.key)}>
149 {o.label}
150 </Button>
151 ))}
152 </Box>
153 );
154 });
155
156 on("ui.close", async ($, e, next) => {
157 if (e.id === DIALOG && e.origin.kind === "person") dialog?.settle(null);
158 return next(e);
159 }).catch(($, e, next) => next(e));
160
161 on("command.run", { command: COMMAND }, async ($, e) => {
162 const arg = e.args.trim().toLowerCase();
163 if (arg === "") return { text: report(await read($, guard), cfg, await $.clock.now()) };
164 if (!["go", "override", "rearm", "ping"].includes(arg)) return { text: "Usage: /last-call [go | override | rearm | ping]" };
165 // Only the person may lift or move the guard, never the model.
166 if (!USER_ORIGINS.has(e.origin?.kind)) return { text: `last-call: /last-call ${arg} only runs when you type it.` };
167 if (arg === "go") return { text: await goNow($) };
168 if (arg === "override") return { text: await override($) };
169 if (arg === "rearm") return { text: await rearm($) };
170 return { text: await ping($, true) };
171 });
172};
173
174// A reading of the usage windows: session.measure's input or $.session.usage().
175async function onReading($, usage) {
176 const now = await $.clock.now();
177 const readings = Object.fromEntries(KINDS.map((k) => [k, windowOf(usage.rateLimits, k)]));
178 const before = await read($, guard);
179 const after = await update($, guard, (s) => {
180 const rolled = rolledKinds(s.windows, readings);
181 const windows = Object.fromEntries(KINDS.map((k) => [k, nextWindow(s.windows[k], readings[k], now)]));
182 let x = { ...s, windows, contextTokens: usage.context?.tokens ?? s.contextTokens };
183 if (x.overrideUntil && now >= x.overrideUntil) x = { ...x, overrideUntil: null };
184 if (rolled.includes(s.binding) && (s.phase === "wrapping" || s.phase === "stopping")) x = { ...x, denials: 0 };
185 return { ...x, ...nextPhase(x, cfg, now, rolled) };
186 });
187 if (after.phase !== before.phase) await announce($, before.phase, after, now);
188 await refreshStatus($);
189}
190
191// Tells the model and the person that the phase changed.
192async function announce($, from, s, now) {
193 const where = s.binding ? `${windowLabel(s.binding)} usage at ${pct(s.windows[s.binding].percent)}` : "";
194 if (s.phase === "wrapping" || s.phase === "stopping") await ensureExcluded($);
195 if (s.phase === "wrapping") {
196 await appendNote($, wrapNote(s, cfg, now, handoffPathOf(s)));
197 await tell($, `${where}: wrapping up at the next clean point.`);
198 } else if (s.phase === "stopping") {
199 await appendNote($, stopNote(s, cfg, handoffPathOf(s)));
200 await tell($, `${where}: limit reached, stopping after the handoff note.`);
201 } else if (s.phase === "normal" && (from === "wrapping" || from === "stopping")) {
202 await tell($, "The usage window reset; carrying on.");
203 }
204}
205
206async function appendNote($, text) {
207 await $.session.append({ message: { type: "user", content: [{ type: "text", text }] } });
208}
209
210// A toast now, and a notification for someone away from the terminal.
211async function tell($, text) {
212 $.ui.toast(`last-call: ${text}`);
213 await $.ui.notify(text, { title: "last-call" });
214}
215
216function handoffPathOf(s) {
217 return s.handoffTarget ?? "(no valid handoff path: check the handoffPath setting)";
218}
219
220// The note's absolute path, checked: inside the project, its existing parent's
221// real path inside the project's real path, and the note itself no symlink.
222// An invalid setting falls back to the default; null when neither is safe.
223async function resolveTarget($) {
224 const root = await $.session.root();
225 for (const setting of [cfg.handoffPath, DEFAULTS.handoffPath]) {
226 const target = targetFrom(root, setting);
227 if (target && (await isSafeTarget($, root, target))) {
228 if (setting !== cfg.handoffPath) $.ui.toast(`last-call: handoffPath "${cfg.handoffPath}" leaves the project; using ${DEFAULTS.handoffPath}`);
229 return target;
230 }
231 }
232 $.ui.toast("last-call: no safe handoff path; the stop will refuse every tool");
233 return null;
234}
235
236async function isSafeTarget($, root, target) {
237 try {
238 const realRoot = normalize((await $.fs.stat(root, { resolve: true })).realPath ?? root);
239 if (await $.fs.exists(target)) {
240 const own = await $.fs.stat(target, { resolve: true });
241 return own.kind === "file" && !own.isLink && isInside(realRoot, normalize(own.realPath ?? target));
242 }
243 for (const dir of parentsOf(target)) {
244 if (!(await $.fs.exists(dir))) continue;
245 const st = await $.fs.stat(dir, { resolve: true });
246 const real = normalize(st.realPath ?? dir);
247 return st.kind === "dir" && (real === realRoot || isInside(realRoot, real));
248 }
249 } catch {
250 // An unreadable path is no safe path.
251 }
252 return false;
253}
254
255// The note was written: remember it, and what it held, for the resume's check.
256async function recordHandoff($, target) {
257 const now = await $.clock.now();
258 const hash = await hashFile($, target);
259 await update($, guard, (x) => ({ ...x, handoff: hash ? { path: target, writtenAt: now, hash } : null }));
260}
261
262async function hashFile($, path) {
263 try {
264 const text = await $.fs.read(path);
265 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
266 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
267 } catch {
268 return null;
269 }
270}
271
272// Adds the note to the repository's local exclude file once, when asked to.
273async function ensureExcluded($) {
274 const s = await read($, guard);
275 if (!cfg.handoffGitIgnored || s.excludeDone || !s.handoffTarget) return;
276 await update($, guard, (x) => ({ ...x, excludeDone: true }));
277 try {
278 const top = await $.process.run(["git", "rev-parse", "--show-toplevel"]);
279 const where = await $.process.run(["git", "rev-parse", "--git-path", "info/exclude"]);
280 if (top.exitCode !== 0 || where.exitCode !== 0) return;
281 const line = excludeLine(top.stdout.trim(), s.handoffTarget);
282 // --git-path answers relative to the working directory unless absolute.
283 const raw = where.stdout.trim();
284 const file = raw && normalize(raw.startsWith("/") || /^[A-Za-z]:/.test(raw) ? raw : `${await $.session.cwd()}/${raw}`);
285 if (!line || !file) return;
286 const text = (await $.fs.exists(file)) ? await $.fs.read(file) : "";
287 if (hasLine(text, line)) return;
288 await $.fs.write(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}# last-call handoff note\n${line}\n`);
289 } catch {
290 // Not a repository, or no git: nothing to ignore.
291 }
292}
293
294// Ends the running turn after the model kept calling tools past the stop.
295async function endTurn($) {
296 if (!turnId) return;
297 try {
298 await $.turn.abort({ turnId });
299 $.ui.toast("last-call: ended the turn at the usage limit");
300 } catch {
301 // The turn ended on its own meanwhile.
302 }
303}
304
305// The pause's state cleared: back to normal work.
306function cleared() {
307 return {
308 phase: "normal", resumeAt: null, pausedAt: null, keepAlive: "off", keepAliveNote: null, pings: 0, lastPingAt: null,
309 denials: 0, returnToPause: false, resumeStarted: false, handoff: null,
310 };
311}
312
313function resetOf(s) {
314 return s.binding ? s.windows[s.binding]?.resetsAt ?? null : null;
315}
316
317// The wrap-up turn ended (or a "send this one only" turn): wait for the reset.
318async function enterPause($) {
319 const now = await $.clock.now();
320 const s = await read($, guard);
321 // A "send this one only" turn whose window reset meanwhile has nothing to wait for.
322 if (s.returnToPause && s.phase === "normal" && (!s.resumeAt || s.resumeAt <= now)) {
323 await update($, guard, (x) => ({ ...x, ...cleared() }));
324 return;
325 }
326 const isShortLived = (await $.store.get(SHORT_LIVED_KEY)) === true;
327 const after = await update($, guard, (x) => {
328 const resumeAt = x.returnToPause && x.resumeAt ? x.resumeAt : resumeTime(x);
329 const decision = keepAliveDecision(cfg, resumeAt ? resumeAt - now : null, isShortLived);
330 return {
331 ...x, phase: "paused", resumeAt, pausedAt: now, lastCacheTouch: now, keepAlive: decision.keepAlive,
332 keepAliveNote: decision.note, pings: 0, lastPingAt: null, denials: 0, returnToPause: false, resumeStarted: false,
333 };
334 });
335 const when = after.resumeAt ? ` until ${fmtWhen(after.resumeAt, now)} (${fmtDuration(after.resumeAt - now)})` : "";
336 await tell($, `Paused${when}. Keep-alive: ${after.keepAlive === "on" ? after.keepAliveNote : `off, ${after.keepAliveNote}`}.`);
337 await refreshStatus($);
338}
339
340async function onTick($) {
341 const now = await $.clock.now();
342 const s = await read($, guard);
343 if (s.overrideUntil && now >= s.overrideUntil) await update($, guard, (x) => ({ ...x, overrideUntil: null }));
344 if (s.phase === "paused" && s.resumeAt && now >= s.resumeAt && !s.resumeStarted) await resume($, false);
345 else if (isPingDue(s, cfg, now)) await ping($, false);
346 await refreshStatus($);
347}
348
349// One keep-alive: the main thread's last request again with a tiny question, so
350// the API serves the cached conversation and so refreshes it.
351async function ping($, isAsked) {
352 if (pinging) return "A ping is already running.";
353 pinging = true;
354 try {
355 const now = await $.clock.now();
356 if (!isAsked && cfg.keepAliveSessions === "one" && !(await takeLock($, now))) return "";
357 await update($, guard, (x) => ({ ...x, lastPingAt: now }));
358 const r = await $.model.fork({ prompt: PING_PROMPT });
359 if (!r.isAnswered && r.reason === "nothing-to-fork") return "Nothing to keep warm yet: the session has no response.";
360 if (!r.isAnswered && r.reason === "api-error") {
361 await update($, guard, (x) => ({ ...x, keepAlive: x.keepAlive === "on" ? "failed" : x.keepAlive, keepAliveNote: `ping failed: HTTP ${r.status} ${r.error}` }));
362 if (!isAsked) await tell($, `Keep-alive stopped: the ping failed (HTTP ${r.status}).`);
363 return `Ping failed: HTTP ${r.status} (${r.error}).`;
364 }
365 const warm = cacheWasWarm(r.usage);
366 const served = r.usage.cache_read_input_tokens;
367 const wrote = r.usage.cache_creation_input_tokens;
368 if (warm === false) {
369 // The cache had lapsed between pings: it lives under an hour here, so
370 // pinging cannot pay. Remember that for later pauses.
371 await $.store.set(SHORT_LIVED_KEY, true);
372 await update($, guard, (x) => ({
373 ...x, lastCacheTouch: now, pings: x.pings + 1,
374 keepAlive: x.phase === "paused" ? "cold" : x.keepAlive,
375 keepAliveNote: "the cache had expired before the ping, so it lives under an hour here; keep-alive stopped",
376 }));
377 if (!isAsked) await tell($, "Keep-alive stopped: the cache had already expired.");
378 return `Ping found the cache cold (${served} tokens read, ${wrote} written). Keep-alive stays off until a ping finds it warm.`;
379 }
380 if (warm === true) await $.store.delete(SHORT_LIVED_KEY);
381 await update($, guard, (x) => ({ ...x, lastCacheTouch: now, pings: x.pings + 1 }));
382 return isAsked ? `Ping answered: ${served} tokens read from the cache, ${wrote} written.` : "";
383 } finally {
384 pinging = false;
385 await refreshStatus($);
386 }
387}
388
389// keepAliveSessions = one: take the machine-wide ping lock, or leave it. $.store
390// has no compare-and-set, so a rare race lets two sessions ping; that costs one
391// extra ping and nothing else.
392async function takeLock($, now) {
393 const me = await $.session.id();
394 if (!lockIsOurs(await $.store.get(LOCK_KEY), me, now)) return false;
395 await $.store.set(LOCK_KEY, { sessionId: me, until: now + cfg.keepAliveMinutes * MINUTE + MIN_PING_GAP_MS });
396 const held = await $.store.get(LOCK_KEY);
397 return held?.sessionId === me;
398}
399
400// The window reset, or the person said go: continue the work, or wait.
401async function resume($, isAsked) {
402 const now = await $.clock.now();
403 // No pings from here on: the resume decides about the cache itself.
404 await update($, guard, (x) => ({ ...x, resumeStarted: true, keepAlive: "off" }));
405 const s = await read($, guard);
406 if (!s.handoff) {
407 await update($, guard, (x) => ({ ...x, ...cleared() }));
408 if (!isAsked) await tell($, "The usage window has reset; there was no unfinished work to resume.");
409 return "Nothing to resume: no handoff note was written. The guard is armed again.";
410 }
411 if (!(await isNoteIntact($, s))) {
412 await update($, guard, (x) => ({ ...x, phase: "ready", keepAlive: "off" }));
413 await tell($, "The window has reset, but the handoff note changed or is gone since it was written. Not resuming by itself: check it, then /last-call go.");
414 if (!isAsked) return "";
415 await update($, guard, (x) => ({ ...x, handoff: null }));
416 return "The handoff note changed since it was written, so it is not trusted. Type your next prompt to continue.";
417 }
418 if (!isAsked && !cfg.autoResume) {
419 await update($, guard, (x) => ({ ...x, phase: "ready", keepAlive: "off" }));
420 await tell($, "The usage window has reset. /last-call go resumes the work.");
421 return "";
422 }
423 const isWarm = isWarmAt(s, now, (await $.store.get(SHORT_LIVED_KEY)) === true);
424 let choice = COLD_FULL;
425 if (!isWarm) {
426 choice = await askCold($, s, isAsked);
427 // The person may have moved on while the question was open (override, a prompt).
428 const current = await read($, guard);
429 if (current.phase !== "paused" && current.phase !== "ready") return "";
430 if (choice !== COLD_COMPACT && choice !== COLD_FULL) {
431 await update($, guard, (x) => ({ ...x, phase: "ready", keepAlive: "off" }));
432 await refreshStatus($);
433 return "Staying paused. /last-call go resumes.";
434 }
435 }
436 await update($, guard, (x) => ({ ...x, ...cleared() }));
437 await refreshStatus($);
438 // From a timer of its own: a command's hook may not wait on a new turn.
439 $.clock.after(0, () => void continueWork($, s.handoff.path, choice === COLD_COMPACT));
440 return choice === COLD_COMPACT ? "Compacting, then resuming from the handoff note." : "Resuming from the handoff note.";
441}
442
443async function continueWork($, path, isCompact) {
444 if (isCompact) {
445 try {
446 await $.session.compact({ instructions: compactInstructions(path) });
447 } catch {
448 // A failed compaction leaves the full context; resume with it.
449 }
450 }
451 await $.prompt.submit({ text: resumePrompt(path) });
452 await tell($, "The usage window has reset; resuming the work.");
453}
454
455// The note this session saw written: still there, unchanged, no symlink.
456async function isNoteIntact($, s) {
457 if (!s.handoff || s.handoff.path !== s.handoffTarget) return false;
458 if (!(await isSafeTarget($, await $.session.root(), s.handoff.path))) return false;
459 if (!(await $.fs.exists(s.handoff.path))) return false;
460 return (await hashFile($, s.handoff.path)) === s.handoff.hash;
461}
462
463// The cold-resume question, answered by the person or, after the timeout, by
464// the coldResumeDefault setting. Asked by /last-call go, it waits for an answer.
465async function askCold($, s, isAsked) {
466 const timeoutMs = isAsked || cfg.coldResumeDefault === "wait" ? null : cfg.coldResumeTimeoutMinutes * MINUTE;
467 const answer = await askDialog($, "last-call: resume", coldQuestion(s, cfg), COLD_OPTIONS, timeoutMs);
468 if (answer === "timeout" || (answer === "unplaced" && !isAsked)) return cfg.coldResumeDefault;
469 return answer;
470}
471
472// A dialog in a pane: resolves the pressed option's key, null when dismissed,
473// "timeout" when timeoutMs passed, "unplaced" when no surface could show it.
474function askDialog($, title, question, options, timeoutMs) {
475 dialog?.settle(null);
476 return new Promise((resolve) => {
477 let timer = null;
478 const settle = (key) => {
479 if (dialog?.settle !== settle) return;
480 dialog = null;
481 timer?.cancel();
482 void $.ui.close({ id: DIALOG }).catch(() => {});
483 resolve(key);
484 };
485 dialog = { question, options, settle };
486 void $.ui.open({ id: DIALOG, title, focus: true, closeOnEscape: true, holdToasts: true })
487 .then((opened) => {
488 if (!opened.isPlaced) settle("unplaced");
489 else if (timeoutMs) timer = $.clock.after(timeoutMs, () => settle("timeout"));
490 })
491 .catch(() => settle("unplaced"));
492 });
493}
494
495async function goNow($) {
496 const s = await read($, guard);
497 if (s.phase !== "paused" && s.phase !== "ready") return "Not paused; nothing to resume.";
498 // Before the reset the limit still holds: lift the guard until then.
499 const now = await $.clock.now();
500 const reset = resetOf(s);
501 if (reset && reset > now) await update($, guard, (x) => ({ ...x, overrideUntil: reset }));
502 return resume($, true);
503}
504
505async function override($) {
506 const now = await $.clock.now();
507 const s = await read($, guard);
508 const reset = resetOf(s) ?? s.windows.five_hour?.resetsAt;
509 const until = reset && reset > now ? reset : now + 5 * 60 * MINUTE;
510 await update($, guard, (x) => ({ ...x, ...cleared(), binding: null, overrideUntil: until }));
511 dialog?.settle(null);
512 await refreshStatus($);
513 return `Guard off until ${fmtWhen(until, now)}. /last-call rearm turns it back on.`;
514}
515
516async function rearm($) {
517 await update($, guard, (x) => ({ ...x, overrideUntil: null }));
518 await onReading($, await $.session.usage());
519 return `Guard armed.\n${report(await read($, guard), cfg, await $.clock.now())}`;
520}
521
522async function refreshStatus($) {
523 const s = await read($, guard);
524 $.ui.status(statusText(s, cfg, await $.clock.now()));
525}
526hooks/lib/config.mjs 59 lines1// last-call's settings: the manifest's userConfig values, defaults filled in and
2// each held to a range the rest of the mod can rely on.
3
4export const DEFAULTS = {
5 autoResume: true,
6 wrapAtPercent: 90,
7 stopAtPercent: 95,
8 sevenDayWrapAtPercent: 90,
9 sevenDayStopPercent: 95,
10 wrapLeadMinutes: 10,
11 keepAlive: true,
12 keepAliveMinutes: 55,
13 keepAliveSessions: "all",
14 handoffPath: ".claude/last-call/handoff.md",
15 handoffGitIgnored: true,
16 coldResumeDefault: "compact",
17 coldResumeTimeoutMinutes: 10,
18};
19
20// The number settings are /config pickers, so they arrive as strings ("90"); a number
21// stored before they were pickers still counts.
22const toNumber = (value) => (typeof value === "string" && value.trim() !== "" ? Number(value) : value);
23const clamp = (value, min, max, fallback) => {
24 const n = toNumber(value);
25 return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
26};
27const oneOf = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback);
28const flag = (value, fallback) => (typeof value === "boolean" ? value : fallback);
29
30export function configFrom(options = {}) {
31 const o = { ...DEFAULTS, ...options };
32 const stopAt = clamp(o.stopAtPercent, 1, 100, DEFAULTS.stopAtPercent);
33 const sevenStop = clamp(o.sevenDayStopPercent, 1, 100, DEFAULTS.sevenDayStopPercent);
34 return {
35 autoResume: flag(o.autoResume, DEFAULTS.autoResume),
36 // A wrap-up threshold above its stop would never be seen.
37 wrapAtPercent: clamp(o.wrapAtPercent, 1, stopAt, Math.min(DEFAULTS.wrapAtPercent, stopAt)),
38 stopAtPercent: stopAt,
39 sevenDayWrapAtPercent: clamp(o.sevenDayWrapAtPercent, 1, sevenStop, Math.min(DEFAULTS.sevenDayWrapAtPercent, sevenStop)),
40 sevenDayStopPercent: sevenStop,
41 wrapLeadMinutes: clamp(o.wrapLeadMinutes, 0, 120, DEFAULTS.wrapLeadMinutes),
42 keepAlive: flag(o.keepAlive, DEFAULTS.keepAlive),
43 // A ping has to land inside the cache's 60-minute lifetime to refresh it.
44 keepAliveMinutes: clamp(o.keepAliveMinutes, 5, 59, DEFAULTS.keepAliveMinutes),
45 keepAliveSessions: oneOf(o.keepAliveSessions, ["all", "one"], DEFAULTS.keepAliveSessions),
46 handoffPath: typeof o.handoffPath === "string" && o.handoffPath.trim() ? o.handoffPath.trim() : DEFAULTS.handoffPath,
47 handoffGitIgnored: flag(o.handoffGitIgnored, DEFAULTS.handoffGitIgnored),
48 coldResumeDefault: oneOf(o.coldResumeDefault, ["compact", "full", "wait"], DEFAULTS.coldResumeDefault),
49 coldResumeTimeoutMinutes: clamp(o.coldResumeTimeoutMinutes, 1, 120, DEFAULTS.coldResumeTimeoutMinutes),
50 };
51}
52
53// The thresholds of one window, by its kind.
54export function limitsOf(cfg, kind) {
55 return kind === "seven_day"
56 ? { wrapAt: cfg.sevenDayWrapAtPercent, stopAt: cfg.sevenDayStopPercent }
57 : { wrapAt: cfg.wrapAtPercent, stopAt: cfg.stopAtPercent };
58}
59hooks/lib/readings.mjs 61 lines1// last-call's readings: the usage windows as the API reports them, their recent
2// history, the burn rate and the projected time to a threshold.
3// Pure functions of plain values; nothing here takes $.
4
5export const MINUTE = 60_000;
6export const HOUR = 60 * MINUTE;
7export const KINDS = ["five_hour", "seven_day"];
8
9const SAMPLE_KEEP_MS = HOUR;
10const RATE_SPAN_MS = 20 * MINUTE;
11const MIN_SPAN_MS = 3 * MINUTE;
12// How far the reset must jump, or the fill fall, to count as a new window.
13const ROLL_RESET_JUMP_MS = HOUR;
14const ROLL_FILL_DROP = 5;
15
16// One window of a reading's rateLimits: { percent, resetsAt (ms) }, or null.
17export function windowOf(rateLimits, kind) {
18 const w = (rateLimits ?? []).find((r) => r.kind === kind);
19 if (!w || !Number.isFinite(w.percentUsed)) return null;
20 const at = w.resetsAt ? Date.parse(w.resetsAt) : NaN;
21 return { percent: w.percentUsed, resetsAt: Number.isFinite(at) ? at : null };
22}
23
24// Whether the window rolled over between two readings.
25export function hasRolled(before, after) {
26 if (!before || !after) return false;
27 if (before.resetsAt && after.resetsAt && after.resetsAt - before.resetsAt > ROLL_RESET_JUMP_MS) return true;
28 return after.percent + ROLL_FILL_DROP < before.percent;
29}
30
31// The window's history with a new reading added; a fall starts it over.
32export function addSample(samples, t, percent) {
33 const last = samples[samples.length - 1];
34 if (last && percent < last.p) return [{ t, p: percent }];
35 if (last && last.p === percent && t - last.t < MINUTE) return samples;
36 return [...samples.filter((s) => t - s.t <= SAMPLE_KEEP_MS), { t, p: percent }];
37}
38
39// Percent per minute over recent history; undefined until there is enough.
40export function burnRate(samples, now) {
41 const recent = samples.filter((s) => now - s.t <= RATE_SPAN_MS);
42 if (recent.length < 2) return undefined;
43 const first = recent[0];
44 const last = recent[recent.length - 1];
45 if (last.t - first.t < MIN_SPAN_MS) return undefined;
46 return Math.max(0, ((last.p - first.p) / (last.t - first.t)) * MINUTE);
47}
48
49// Minutes until the fill reaches target at this rate; undefined when not climbing.
50export function minutesTo(percent, target, rate) {
51 if (rate === undefined || rate <= 0) return undefined;
52 return Math.max(0, (target - percent) / rate);
53}
54
55// A window's state after a reading: the new figures and the sample history.
56export function nextWindow(before, reading, now) {
57 if (!reading) return before;
58 const samples = before && !hasRolled(before, reading) ? before.samples : [];
59 return { percent: reading.percent, resetsAt: reading.resetsAt, samples: addSample(samples, now, reading.percent) };
60}
61hooks/lib/phases.mjs 75 lines1// last-call's phases: normal → wrapping → stopping → paused → ready → normal.
2// Pure functions of plain values; nothing here takes $.
3
4import { limitsOf } from "./config.mjs";
5import { KINDS, MINUTE, burnRate, minutesTo, hasRolled } from "./readings.mjs";
6
7const RANK = { normal: 0, wrapping: 1, stopping: 2 };
8const RESUME_GRACE_MS = 2 * MINUTE;
9
10export function initialState() {
11 return {
12 phase: "normal",
13 binding: null,
14 windows: { five_hour: null, seven_day: null },
15 contextTokens: null,
16 resumeAt: null,
17 pausedAt: null,
18 lastCacheTouch: null,
19 keepAlive: "off",
20 keepAliveNote: null,
21 pings: 0,
22 lastPingAt: null,
23 handoff: null,
24 handoffTarget: null,
25 excludeDone: false,
26 denials: 0,
27 overrideUntil: null,
28 returnToPause: false,
29 resumeStarted: false,
30 cwd: null,
31 };
32}
33
34// What one window alone asks for, and how soon it reaches its stop.
35export function windowVerdict(win, cfg, kind, now) {
36 if (!win) return { phase: "normal", eta: undefined };
37 const { wrapAt, stopAt } = limitsOf(cfg, kind);
38 const eta = minutesTo(win.percent, stopAt, burnRate(win.samples, now));
39 if (win.percent >= stopAt) return { phase: "stopping", eta: 0 };
40 if (win.percent >= wrapAt || (eta !== undefined && eta <= cfg.wrapLeadMinutes)) return { phase: "wrapping", eta };
41 return { phase: "normal", eta };
42}
43
44// The phase and binding window after a reading. A pause is left only by its
45// timer, a command or the person; wrapping and stopping only when the binding
46// window rolled over (rolled: the kinds that rolled in this reading).
47export function nextPhase(state, cfg, now, rolled = []) {
48 if (state.phase === "paused" || state.phase === "ready") return { phase: state.phase, binding: state.binding };
49 if (state.overrideUntil && now < state.overrideUntil) return { phase: "normal", binding: null };
50
51 let best = { phase: "normal", binding: null, eta: Infinity };
52 for (const kind of KINDS) {
53 const v = windowVerdict(state.windows[kind], cfg, kind, now);
54 const eta = v.eta ?? Infinity;
55 if (RANK[v.phase] > RANK[best.phase] || (RANK[v.phase] === RANK[best.phase] && v.phase !== "normal" && eta < best.eta)) {
56 best = { phase: v.phase, binding: kind, eta };
57 }
58 }
59
60 // Sticky: the phase in force stays until its binding window rolls over.
61 const held = state.binding && !rolled.includes(state.binding) ? state.phase : "normal";
62 if (RANK[held] > RANK[best.phase]) return { phase: held, binding: state.binding };
63 return { phase: best.phase, binding: best.binding };
64}
65
66export function rolledKinds(windowsBefore, readings) {
67 return KINDS.filter((k) => hasRolled(windowsBefore[k], readings[k]));
68}
69
70// When to resume: just after the binding window's reset; null when unknown.
71export function resumeTime(state) {
72 const resetsAt = state.binding ? state.windows[state.binding]?.resetsAt : null;
73 return resetsAt ? resetsAt + RESUME_GRACE_MS : null;
74}
75hooks/lib/keepalive.mjs 67 lines1// last-call's keep-alive: whether pinging pays, when a ping is due, and whether
2// the cache is still warm. Pure functions of plain values; nothing here takes $.
3
4import { MINUTE, HOUR } from "./readings.mjs";
5
6// The cache's lifetime the mod plans around (Claude Code writes 1-hour entries;
7// a ping that finds the cache cold proves otherwise and keep-alive stops).
8export const CACHE_TTL_MS = HOUR;
9// A cache read costs 0.1x of plain input; a 1-hour cache write 2x.
10export const CACHE_READ_COST = 0.1;
11export const CACHE_WRITE_COST = 2.0;
12// Never two pings closer than this, whatever the tick does.
13export const MIN_PING_GAP_MS = 5 * MINUTE;
14// Below this many tokens the cache may hold nothing, so a ping proves nothing.
15const MIN_CACHEABLE_TOKENS = 4096;
16
17// The trade for a wait: N pings at a cache read each, or one cache write.
18export function keepAlivePlan(waitMs, intervalMs) {
19 const pings = waitMs <= CACHE_TTL_MS ? 0 : Math.ceil((waitMs - CACHE_TTL_MS) / intervalMs);
20 const pingCost = pings * CACHE_READ_COST;
21 return { pings, pingCost, isWorth: pings > 0 && pingCost < CACHE_WRITE_COST };
22}
23
24// The most pings one pause may send: the plan's count and one to spare.
25export function maxPings(state, cfg) {
26 if (!state.resumeAt || !state.pausedAt) return 0;
27 return Math.ceil((state.resumeAt - state.pausedAt) / (cfg.keepAliveMinutes * MINUTE)) + 1;
28}
29
30// A ping is due when the cache would otherwise lapse before the resume.
31export function isPingDue(state, cfg, now) {
32 if (state.phase !== "paused" || state.keepAlive !== "on" || !state.lastCacheTouch) return false;
33 if (state.resumeAt && state.lastCacheTouch + CACHE_TTL_MS >= state.resumeAt) return false;
34 if (state.lastPingAt && now - state.lastPingAt < MIN_PING_GAP_MS) return false;
35 if (state.pings >= maxPings(state, cfg)) return false;
36 return now - state.lastCacheTouch >= cfg.keepAliveMinutes * MINUTE;
37}
38
39// What a ping's usage says of the cache: true warm, false cold, undefined unknown.
40export function cacheWasWarm(usage) {
41 const total = usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.input_tokens;
42 if (total < MIN_CACHEABLE_TOKENS) return undefined;
43 return usage.cache_read_input_tokens >= total / 2;
44}
45
46// Whether the cache still holds the conversation at the resume.
47export function isWarmAt(state, now, isShortLived) {
48 if (!state.lastCacheTouch) return false;
49 return now - state.lastCacheTouch < (isShortLived ? 5 * MINUTE : CACHE_TTL_MS);
50}
51
52// The pause's keep-alive decision: { keepAlive, note }.
53export function keepAliveDecision(cfg, waitMs, isShortLived) {
54 if (!cfg.keepAlive) return { keepAlive: "off", note: "turned off in settings" };
55 if (isShortLived) return { keepAlive: "off", note: "an earlier ping found the cache expired; /last-call ping re-tests it" };
56 if (waitMs === null) return { keepAlive: "off", note: "reset time unknown" };
57 const plan = keepAlivePlan(waitMs, cfg.keepAliveMinutes * MINUTE);
58 if (plan.pings === 0) return { keepAlive: "off", note: "the cache outlives the wait" };
59 if (!plan.isWorth) return { keepAlive: "off", note: `${plan.pings} pings would cost more than reloading the context once` };
60 return { keepAlive: "on", note: `${plan.pings} ping${plan.pings === 1 ? "" : "s"} planned (~${plan.pingCost.toFixed(1)}x context vs ${CACHE_WRITE_COST}x to reload)` };
61}
62
63// The ping lock for keepAliveSessions = one: free, or held by this session.
64export function lockIsOurs(lock, sessionId, now) {
65 return !lock || lock.sessionId === sessionId || lock.until <= now;
66}
67hooks/lib/texts.mjs 203 lines1// last-call's words: the notes the model reads, the status line, notifications
2// and the /last-call report. Pure functions of plain values; nothing here takes $.
3
4import { limitsOf } from "./config.mjs";
5import { MINUTE, burnRate, minutesTo } from "./readings.mjs";
6
7const LABEL = { five_hour: "5-hour", seven_day: "7-day" };
8const SHORT = { five_hour: "5h", seven_day: "7d" };
9
10export const pct = (p) => `${Math.round(p * 10) / 10}%`;
11
12export function fmtClock(ms) {
13 const d = new Date(ms);
14 return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
15}
16
17// A reset time: the clock when it is within a day, else the date as well.
18export function fmtWhen(ms, now) {
19 if (ms - now < 20 * 60 * MINUTE) return fmtClock(ms);
20 const d = new Date(ms);
21 return `${d.toISOString().slice(5, 10)} ${fmtClock(ms)}`;
22}
23
24export function fmtDuration(ms) {
25 const m = Math.max(0, Math.round(ms / MINUTE));
26 if (m < 60) return `${m}m`;
27 const h = Math.floor(m / 60);
28 if (h >= 48) return `${Math.round(h / 24)}d`;
29 return m % 60 ? `${h}h${String(m % 60).padStart(2, "0")}m` : `${h}h`;
30}
31
32export const windowLabel = (kind) => LABEL[kind] ?? kind;
33
34function etaOf(win, cfg, kind, now) {
35 return minutesTo(win.percent, limitsOf(cfg, kind).stopAt, burnRate(win.samples, now));
36}
37
38// The wrap-up note: where things stand, what to do, and the handoff template.
39export function wrapNote(state, cfg, now, handoffPath) {
40 const kind = state.binding;
41 const win = state.windows[kind];
42 const { stopAt } = limitsOf(cfg, kind);
43 const eta = etaOf(win, cfg, kind, now);
44 const when = eta !== undefined ? ` (projected to reach ${stopAt}% in ~${Math.max(1, Math.round(eta))} min)` : "";
45 const reset = win.resetsAt ? ` The window resets at ${fmtWhen(win.resetsAt, now)}.` : "";
46 return [
47 `[last-call] The ${windowLabel(kind)} usage limit is at ${pct(win.percent)}${when}.${reset}`,
48 "Find the nearest clean stopping point: finish or safely shelve the current step, and do not start new sub-tasks or subagents.",
49 `If work remains, write a handoff note to ${handoffPath} with the Write tool, using this template:`,
50 "",
51 handoffTemplate(kind, win.percent, now),
52 "",
53 "Then end your turn with a one-line summary.",
54 `At ${stopAt}% every tool except writing that note is refused. The session then pauses and continues from the note after the reset.`,
55 ].join("\n");
56}
57
58export function stopNote(state, cfg, handoffPath) {
59 const kind = state.binding;
60 const win = state.windows[kind];
61 return [
62 `[last-call] The ${windowLabel(kind)} usage limit is at ${pct(win.percent)}, past the ${limitsOf(cfg, kind).stopAt}% stop. Stop working now.`,
63 `If work remains and the handoff note is not written yet, write it to ${handoffPath} (Write tool, template as given before), then end your turn with a one-line summary. Every other tool call is refused.`,
64 ].join("\n");
65}
66
67export function handoffTemplate(kind, percent, now) {
68 return [
69 `# Handoff (last-call, ${new Date(now).toISOString().slice(0, 16).replace("T", " ")} UTC, ${windowLabel(kind)} at ${pct(percent)})`,
70 "",
71 "## Task",
72 "The original request, in one or two lines.",
73 "",
74 "## Done",
75 "- What is finished, and where (files, commits).",
76 "",
77 "## Next step",
78 "The exact next action, specific enough to start without re-reading the conversation.",
79 "",
80 "## Remaining",
81 "- Steps after the next one, in order.",
82 "",
83 "## State not in files",
84 "- Decisions made, assumptions, commands running, things tried that failed.",
85 "",
86 "## Open questions",
87 "- Anything that needs the person.",
88 ].join("\n");
89}
90
91export function denyText(state, handoffPath) {
92 return state.handoff
93 ? "last-call: the usage limit is reached and the handoff note is saved. End your turn now with a one-line summary."
94 : `last-call: the usage limit is reached. The only tool allowed is writing the handoff note to ${handoffPath}. Write it if work remains, then end your turn.`;
95}
96
97export function pausedDenyText(state, now) {
98 const until = state.resumeAt ? ` until ${fmtWhen(state.resumeAt, now)}` : "";
99 return `last-call: the session is paused for the usage limit${until}, and no tools run during the pause. End your turn now; the work continues after the reset.`;
100}
101
102// The resume prompt: the note is the model's own notes, to check before acting.
103export function resumePrompt(handoffPath) {
104 return [
105 "last-call: the usage window has reset, so the work can continue.",
106 `Read your handoff note at ${handoffPath}. It holds your own notes from before the pause.`,
107 "Check them against the repository's current state before acting on them, continue from the next step, and delete the note once you have picked it up.",
108 ].join(" ");
109}
110
111export function statusText(state, cfg, now) {
112 const { phase, windows } = state;
113 const seven = windows.seven_day && windows.seven_day.percent >= 80 && state.binding !== "seven_day" ? ` · 7d ${pct(windows.seven_day.percent)}` : "";
114 if (phase === "paused") {
115 const when = state.resumeAt ? `until ${fmtWhen(state.resumeAt, now)} (${fmtDuration(state.resumeAt - now)})` : "reset time unknown";
116 const ka = state.keepAlive === "on" ? ` · keep-alive ${state.pings} ping${state.pings === 1 ? "" : "s"}` : "";
117 return `⏸ last-call paused ${when}${ka}${seven}`;
118 }
119 if (phase === "ready") return "▶ last-call: the window has reset · /last-call go to resume";
120
121 const kind = state.binding ?? (windows.five_hour ? "five_hour" : "seven_day");
122 const win = windows[kind];
123 if (!win) return undefined;
124 const head = `${SHORT[kind]} ${pct(win.percent)}`;
125 if (state.overrideUntil && now < state.overrideUntil) return `${head} · guard off until ${fmtWhen(state.overrideUntil, now)}${seven}`;
126 const { stopAt } = limitsOf(cfg, kind);
127 if (phase === "stopping") return `⛔ ${head} · stopping: handoff only${seven}`;
128 const rate = burnRate(win.samples, now);
129 const eta = minutesTo(win.percent, stopAt, rate);
130 const etaText = eta !== undefined ? ` · ${stopAt}% in ~${fmtDuration(eta * MINUTE)}` : "";
131 if (phase === "wrapping") return `⚠ ${head} · wrapping up${etaText}${seven}`;
132 const burn = rate !== undefined ? ` · ${pct(rate * 10)}/10m` : "";
133 const reset = win.resetsAt ? ` · resets ${fmtWhen(win.resetsAt, now)}` : "";
134 return `${head}${burn}${etaText}${reset}${seven}`;
135}
136
137export const PING_PROMPT = "last-call keep-alive ping: this only keeps the prompt cache warm. Reply with the single word: ok";
138
139export const ASK_SEND_OFF = "Send, guard off until the reset";
140export const ASK_SEND_ONCE = "Send this one only";
141export const ASK_DONT = "Don't send";
142
143export function pausedQuestion(state, now) {
144 const kind = state.binding;
145 const win = kind ? state.windows[kind] : null;
146 const until = state.resumeAt ? ` until ${fmtWhen(state.resumeAt, now)}` : "";
147 const fill = win ? ` (${windowLabel(kind)} usage at ${pct(win.percent)})` : "";
148 return `last-call has paused for the usage limit${until}${fill}. Send this prompt anyway? With "${ASK_SEND_ONCE}" the guard stays on, so the turn may stop again at once.`;
149}
150
151export const COLD_COMPACT = "compact";
152export const COLD_FULL = "full";
153export const COLD_LATER = "later";
154
155export function coldQuestion(state, cfg) {
156 const tokens = state.contextTokens ? ` (~${Math.round(state.contextTokens / 1000)}k tokens)` : "";
157 const fallback = cfg.coldResumeDefault === "wait"
158 ? "Without an answer it stays paused."
159 : `Without an answer in ${cfg.coldResumeTimeoutMinutes} min: ${cfg.coldResumeDefault === "compact" ? "compact, then resume" : "resume with the full context"}.`;
160 return [
161 `The usage window has reset. The prompt cache has expired, so resuming reloads the full context${tokens}.`,
162 "Compacting first makes that reload smaller; the handoff note keeps what matters.",
163 fallback,
164 ].join(" ");
165}
166
167export const COLD_OPTIONS = [
168 { key: COLD_COMPACT, label: "Compact first, then resume" },
169 { key: COLD_FULL, label: "Resume with the full context" },
170 { key: COLD_LATER, label: "Not now (/last-call go resumes)" },
171];
172
173export function compactInstructions(handoffPath) {
174 return `Keep the path of the handoff note (${handoffPath}), the task, and its next step.`;
175}
176
177export function report(state, cfg, now) {
178 const lines = [`last-call: ${state.phase}${state.binding ? ` (${windowLabel(state.binding)} window)` : ""}`];
179 for (const kind of ["five_hour", "seven_day"]) {
180 const win = state.windows[kind];
181 if (!win) {
182 lines.push(`${windowLabel(kind)}: no reading yet (one arrives with the next response)`);
183 continue;
184 }
185 const { stopAt } = limitsOf(cfg, kind);
186 const rate = burnRate(win.samples, now);
187 const eta = minutesTo(win.percent, stopAt, rate);
188 const burn = rate !== undefined ? `, ${pct(rate * 10)} per 10 min${eta !== undefined ? `, ${stopAt}% in ~${fmtDuration(eta * MINUTE)}` : ""}` : "";
189 const reset = win.resetsAt ? `, resets ${fmtWhen(win.resetsAt, now)} (in ${fmtDuration(win.resetsAt - now)})` : "";
190 lines.push(`${windowLabel(kind)}: ${pct(win.percent)}${burn}${reset}`);
191 }
192 lines.push(`Thresholds: 5-hour wrap-up ${cfg.wrapAtPercent}%, stop ${cfg.stopAtPercent}%; 7-day wrap-up ${cfg.sevenDayWrapAtPercent}%, stop ${cfg.sevenDayStopPercent}%; or ${cfg.wrapLeadMinutes} min before a projected stop`);
193 lines.push(`Resume: ${cfg.autoResume ? "automatic" : "on /last-call go"}; cold cache: ${cfg.coldResumeDefault} after ${cfg.coldResumeTimeoutMinutes} min`);
194 lines.push(`Keep-alive: ${cfg.keepAlive ? `every ${cfg.keepAliveMinutes} min while paused, ${cfg.keepAliveSessions === "one" ? "one session at a time" : "every session"}` : "off"}`);
195 if (state.phase === "paused") {
196 lines.push(state.resumeAt ? `Paused until ${fmtWhen(state.resumeAt, now)} (in ${fmtDuration(state.resumeAt - now)})` : "Paused; the reset time is unknown: /last-call go resumes");
197 lines.push(`This pause: keep-alive ${state.keepAlive}${state.keepAliveNote ? ` (${state.keepAliveNote})` : ""}, ${state.pings} ping${state.pings === 1 ? "" : "s"} sent`);
198 }
199 lines.push(`Handoff note: ${state.handoffTarget ?? "no safe path (check the handoffPath setting)"}${state.handoff ? " (written)" : ""}`);
200 if (state.overrideUntil && now < state.overrideUntil) lines.push(`Guard off until ${fmtWhen(state.overrideUntil, now)} (/last-call rearm turns it back on)`);
201 return lines.join("\n");
202}
203hooks/lib/handoff.mjs 70 lines1// last-call's handoff note: where it may live, whether a tool call targets it,
2// and its line in the repository's local exclude file.
3// Pure functions of plain values; nothing here takes $.
4
5// A path with forward slashes, no empty or "." segments, ".." folded in.
6// Returns null when ".." climbs above the start.
7export function normalize(path) {
8 const isAbs = path.startsWith("/") || /^[A-Za-z]:/.test(path);
9 const parts = [];
10 for (const seg of path.replace(/\\/g, "/").split("/")) {
11 if (seg === "" || seg === ".") continue;
12 if (seg === "..") {
13 if (parts.length === 0 || (parts.length === 1 && /^[A-Za-z]:$/.test(parts[0]))) return null;
14 parts.pop();
15 } else parts.push(seg);
16 }
17 const joined = parts.join("/");
18 return isAbs && !/^[A-Za-z]:/.test(joined) ? `/${joined}` : joined;
19}
20
21const sameCase = (p) => (/^[A-Za-z]:/.test(p) ? p.toLowerCase() : p);
22
23// Whether path lies strictly inside root (both normalized, absolute).
24export function isInside(root, path) {
25 const r = sameCase(root).replace(/\/$/, "");
26 return sameCase(path).startsWith(`${r}/`);
27}
28
29// The note's absolute path from the setting, or null when it would leave root.
30export function targetFrom(root, setting) {
31 // The path is quoted to the model: no control characters to smuggle lines in.
32 if (/[\u0000-\u001f\u007f]/.test(setting)) return null;
33 const isAbs = setting.startsWith("/") || /^[A-Za-z]:[\\/]/.test(setting);
34 const path = normalize(isAbs ? setting : `${root}/${setting}`);
35 const base = normalize(root);
36 return path && base && isInside(base, path) ? path : null;
37}
38
39// Whether a tool call's file_path is exactly the note.
40export function isTarget(target, filePath) {
41 if (!target || typeof filePath !== "string") return false;
42 const p = normalize(filePath);
43 return p !== null && sameCase(p) === sameCase(target);
44}
45
46// The parents of a path, nearest first: where an existence check starts.
47export function parentsOf(path) {
48 const out = [];
49 let p = path;
50 while (p.includes("/") && p.lastIndexOf("/") > 0) {
51 p = p.slice(0, p.lastIndexOf("/"));
52 out.push(p);
53 }
54 return out;
55}
56
57// The exclude-file line for the note, relative to the repository's top level:
58// anchored with "/", glob characters escaped. null for a path outside the
59// repository or one a line cannot hold.
60export function excludeLine(topLevel, target) {
61 const top = normalize(topLevel);
62 if (!top || !isInside(top, target) || /[\r\n]/.test(target)) return null;
63 const rel = target.slice(top.replace(/\/$/, "").length + 1);
64 return `/${rel.replace(/[\\*?[\]!#]/g, (c) => `\\${c}`)}`;
65}
66
67export function hasLine(text, line) {
68 return text.split(/\r?\n/).some((l) => l.trim() === line);
69}
70types/index.d.ts 35 lines1export type WindowKind = "five_hour" | "seven_day";
2export type Sample = { t: number; p: number };
3export type WindowState = { percent: number; resetsAt: number | null; samples: Sample[] };
4export type Phase = "normal" | "wrapping" | "stopping" | "paused" | "ready";
5export type KeepAliveState = "off" | "on" | "cold" | "failed";
6export type Handoff = { path: string; writtenAt: number; hash: string };
7
8export type GuardState = {
9 phase: Phase;
10 binding: WindowKind | null;
11 windows: { five_hour: WindowState | null; seven_day: WindowState | null };
12 contextTokens: number | null;
13 resumeAt: number | null;
14 pausedAt: number | null;
15 lastCacheTouch: number | null;
16 keepAlive: KeepAliveState;
17 keepAliveNote: string | null;
18 pings: number;
19 lastPingAt: number | null;
20 handoff: Handoff | null;
21 handoffTarget: string | null;
22 excludeDone: boolean;
23 denials: number;
24 overrideUntil: number | null;
25 returnToPause: boolean;
26 resumeStarted: boolean;
27 cwd: string | null;
28};
29
30declare module 'claude-code' {
31 interface PluginState {
32 'last-call': { guard: GuardState };
33 }
34}
35