SLOPSHOPPER

last-call

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.

newpaneguardcommandtoaststatus
v0.1.1MITupdated 2026-10-10Sunkanxx/Mods/last-call
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · last-call
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ last-call │ ⏺ Read(src/auth.ts) │ last-call: no safe handoff path; the stop │ ⎿ Read 6 lines │ will refuse every tool │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /last-call ⎿ last-call: last-call: normal ⎿ last-call: 5-hour: 31% ⎿ last-call: 7-day: no reading yet (one arrives with the next response) ⎿ last-call: Thresholds: 5-hour wrap-up 90%, stop 95%; 7-day wrap-up 90%, stop 95%; or 10 min before a projected stop ⎿ last-call: Resume: automatic; cold cache: compact after 10 min ⎿ last-call: Keep-alive: every 55 min while paused, every session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ last-call: 5h 31%
README

last-call

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.

What it does

  • Watches the 5-hour and 7-day usage windows in the status line: fill, burn rate, time to the stop, and the reset time.
  • Wraps up at 90% of either window, or earlier when the burn rate puts the stop less than 10 minutes away. The model is told to finish or shelve the current step and, if work remains, to write a handoff note from a fixed template (Task, Done, Next step, Remaining, State not in files, Open questions).
  • Stops at 95%. Every tool is refused except writing the handoff note; if the model keeps calling tools, the turn is ended.
  • Pauses until the reset (plus 2 minutes). While it waits, it can keep the cache warm: about every 55 minutes it re-sends the conversation's last request, on the same model, with a one-word question. The API serves that from the cache, which keeps it alive. Pings run only when they cost less than reloading the context once:
  • a cache read is 0.1× of input, against 2× for a 1-hour cache write;
  • a few hours' wait passes; a long 7-day wait does not.
  • If a ping finds the cache already expired, keep-alive switches itself off.
  • Resumes automatically after the reset, with a prompt to read the handoff note, check it against the repository, continue, and delete it.
  • If the cache has expired, you are asked: compact first, resume with the full context, or not now.
  • With nobody there, the coldResumeDefault setting applies after 10 minutes (compact by default).
  • Asks before sending a prompt you type during the pause: send with the guard off until the reset, send this one only, or don't send.

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.

Commands

CommandWhat it does
/last-callStatus: fills, burn rate, thresholds, pause and keep-alive state
/last-call goResume now (before the reset, this also turns the guard off until the reset)
/last-call overrideTurn the guard off until the reset
/last-call rearmTurn it back on
/last-call pingSend 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.

Settings

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.

SettingChoicesDefault
autoResumeon / offonOff: wait for /last-call go or your next prompt
wrapAtPercent / stopAtPercent70 80 85 90 95 / 85 90 95 98 10090 / 955-hour wrap-up and stop
sevenDayWrapAtPercent / sevenDayStopPercentsame as 5-hour90 / 957-day wrap-up and stop
wrapLeadMinutes0 5 10 15 20 3010Also wrap up when the stop is projected this close
keepAliveon / offonKeep the cache warm while paused
keepAliveMinutes15 30 45 50 5555Minutes between pings
keepAliveSessionsall oneallone: only one session on this machine pings at a time
handoffPathany path.claude/last-call/handoff.mdMust stay inside the project
handoffGitIgnoredon / offonAdds the note to .git/info/exclude (no tracked file changes)
coldResumeDefaultcompact full waitcompactWhen nobody answers
coldResumeTimeoutMinutes1 5 10 15 30 6010How long the cold-resume question waits

A wrap-up picked above its stop is held to the stop.

Limits

  • Usage readings arrive with API responses. Usage from other sessions, devices or claude.ai shows up at the next response.
  • The pause lives in the running session. Quitting Claude Code during the pause ends it.
  • Compacting before a cold resume saves less than it may sound: compaction itself sends the whole conversation once (about 1× of input, against about 2× for a full cache reload).
  • The resume only starts by itself from a handoff note this session saw written, unchanged since. A note that changed during the pause, or one already in the repository, is not acted on without you.
  • If the mod itself fails, it lets the work through. The stop is not guaranteed in that case.
  • API-key users have no usage windows, so the mod stays idle.

Install

/plugin install last-call --marketplace Sunkanxx/Mods
Source 8 files
hooks/last-call.jsx 526 lines
1// 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}
526
hooks/lib/config.mjs 59 lines
1// 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}
59
hooks/lib/readings.mjs 61 lines
1// 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}
61
hooks/lib/phases.mjs 75 lines
1// 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}
75
hooks/lib/keepalive.mjs 67 lines
1// 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}
67
hooks/lib/texts.mjs 203 lines
1// 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}
203
hooks/lib/handoff.mjs 70 lines
1// 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}
70
types/index.d.ts 35 lines
1export 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