SLOPSHOPPER

session-limit

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

new
★ 17v?MITupdated 2026-10-07bendrucker/claude/plugins/session-limit
A shopper browsing a rack in a slop shop
README

Session Limit

Steer the model to wind down before a usage block runs out and spills into overage.

Contents

  • Mod: 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: bands.ts holds the thresholds and their guidance. Each band fires once per block and re-arms when the window resets.
  • Types: types/index.d.ts declares the announced-band state.

Tests

bun scripts/mod-test.ts session-limit

Source 3 files
mod/register.ts 54 lines
1import 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}
54
mod/bands.ts 108 lines
1import 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}
108
types/index.d.ts 8 lines
1export type SessionLimitAnnounced = Record<string, { band: number; resetsAt: string }>;
2
3declare module "claude-code" {
4  interface PluginState {
5    "session-limit": { announced: SessionLimitAnnounced };
6  }
7}
8