Warn the model mid-turn as the 5-hour or 7-day limit nears

Steer the model to wind down before a usage block runs out and spills into overage.
register.ts reads the rate-limit windows on each session.measure and appends guidance mid-turn when a window crosses a band. Each append is logged through mod-events as an inject event. Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.bands.ts holds the thresholds and their guidance. Each band fires once per block and re-arms when the window resets.types/index.d.ts declares the announced-band state.bun scripts/mod-test.ts session-limit
mod/register.ts 54 lines1import type { On } from "claude-code";
2import { evaluate } from "./bands";
3
4const MOD = "session-limit";
5const ANNOUNCED = { plugin: "session-limit", key: "announced" } as const;
6
7export function register(on: On): void {
8 on("session.start", ($, e, next) => {
9 void $.modEvents.emit({ mod: MOD, event: "session.start" });
10 return next(e);
11 });
12
13 on("session.measure", async ($, e, next) => {
14 if (!e.changed.includes("rateLimits")) return next(e);
15
16 const { value: prev = {} } = await $.state.get(ANNOUNCED);
17 const { announced, crossings } = evaluate(e.rateLimits, prev, await $.clock.now());
18
19 if (crossings.length === 0) {
20 await $.state.set(ANNOUNCED, announced);
21 } else {
22 const text = crossings.map((crossing) => crossing.message).join("\n\n");
23 const outcome = await $.session
24 .append({ message: { type: "user", content: [{ type: "text", text }] } })
25 .then(
26 (result) => ({
27 ok: result.deny === undefined,
28 uuid: result.uuid ?? null,
29 error: result.deny ?? null,
30 }),
31 (error: unknown) => ({ ok: false, uuid: null, error: String(error) }),
32 );
33 // A refused append leaves the bands unannounced so the next measurement retries.
34 if (outcome.ok) await $.state.set(ANNOUNCED, announced);
35 void $.modEvents.emit({
36 mod: MOD,
37 event: "inject",
38 ok: outcome.ok,
39 detail: {
40 crossings: crossings.map(({ kind, threshold, percentUsed, resetsAt }) => ({
41 kind,
42 threshold,
43 percentUsed,
44 resetsAt: resetsAt ?? null,
45 })),
46 uuid: outcome.uuid,
47 error: outcome.error,
48 },
49 });
50 }
51 return next(e);
52 });
53}
54mod/bands.ts 108 lines1import type { SessionRateLimit } from "claude-code";
2import type { SessionLimitAnnounced } from "../types";
3
4export interface Band {
5 threshold: number;
6 message: (resetsAt: string | undefined, nowMs: number) => string;
7}
8
9export type Announced = SessionLimitAnnounced;
10
11export interface Crossing {
12 kind: string;
13 threshold: number;
14 percentUsed: number;
15 resetsAt: string | undefined;
16 message: string;
17}
18
19// Auto-scheduling a wake-up caps out around an hour. Past this horizon the model
20// defers to the user.
21const WAKEUP_HORIZON_MS = 55 * 60 * 1000;
22
23export const BANDS: Record<string, Band[]> = {
24 five_hour: [
25 {
26 threshold: 90,
27 message: (resetsAt) =>
28 `You are at 90% of the current 5-hour usage block (resets ${formatResetTime(resetsAt)}). Favor efficient work and avoid starting large non-essential tasks.`,
29 },
30 {
31 threshold: 95,
32 message: (resetsAt) =>
33 `You are at 95% of the current 5-hour usage block (resets ${formatResetTime(resetsAt)}). Prefer finishing in-flight work over starting anything new, and batch tool calls.`,
34 },
35 {
36 threshold: 100,
37 message: (resetsAt, nowMs) => {
38 const reset = formatResetTime(resetsAt);
39 const msUntilReset = resetsAt == null ? Number.NaN : Date.parse(resetsAt) - nowMs;
40 const withinHorizon = msUntilReset > 0 && msUntilReset <= WAKEUP_HORIZON_MS;
41 const resume = withinHorizon
42 ? `then schedule a wake-up for just after ${reset} (no need to ask) so work resumes on a fresh block, and stop`
43 : `then tell the user to return at ${reset} to resume on a fresh block, and stop`;
44 return `The 5-hour usage block is exhausted (resets ${reset}). Every further request now spends overage credits. Finish only in-flight work, ${resume}. Start no new work.`;
45 },
46 },
47 ],
48 seven_day: [
49 {
50 threshold: 95,
51 message: (resetsAt) =>
52 `You are at 95% of the 7-day usage limit. A 5-hour wait will not restore this. Minimize spend until the weekly reset (${formatResetTime(resetsAt)}).`,
53 },
54 ],
55};
56
57export function formatResetTime(resetsAt: string | undefined): string {
58 if (resetsAt == null) return "at an unknown time";
59 return new Date(resetsAt).toLocaleString([], {
60 weekday: "short",
61 hour: "numeric",
62 minute: "2-digit",
63 });
64}
65
66export function crossedBand(percentUsed: number, bands: readonly Band[]): Band | undefined {
67 return bands.findLast((band) => percentUsed >= band.threshold);
68}
69
70// A new resetsAt means the block rolled over, so its bands re-arm.
71function announcedBand(prev: Announced, kind: string, resetsAt: string): number {
72 const entry = prev[kind];
73 return entry?.resetsAt === resetsAt ? entry.band : 0;
74}
75
76export function evaluate(
77 limits: readonly SessionRateLimit[],
78 prev: Announced,
79 nowMs: number,
80): { announced: Announced; crossings: Crossing[] } {
81 const announced: Announced = { ...prev };
82 const crossings: Crossing[] = [];
83
84 for (const limit of limits) {
85 const bands = BANDS[limit.kind];
86 if (!bands) continue;
87
88 const resetsAt = limit.resetsAt ?? "";
89 const prior = announcedBand(prev, limit.kind, resetsAt);
90 announced[limit.kind] = { band: prior, resetsAt };
91
92 // Announce only the highest band crossed, and only once per block.
93 const crossed = crossedBand(limit.percentUsed, bands);
94 if (!crossed || crossed.threshold <= prior) continue;
95
96 announced[limit.kind] = { band: crossed.threshold, resetsAt };
97 crossings.push({
98 kind: limit.kind,
99 threshold: crossed.threshold,
100 percentUsed: limit.percentUsed,
101 resetsAt: limit.resetsAt,
102 message: crossed.message(limit.resetsAt, nowMs),
103 });
104 }
105
106 return { announced, crossings };
107}
108types/index.d.ts 8 lines1export type SessionLimitAnnounced = Record<string, { band: number; resetsAt: string }>;
2
3declare module "claude-code" {
4 interface PluginState {
5 "session-limit": { announced: SessionLimitAnnounced };
6 }
7}
8