SLOPSHOPPER

graceful-stop

Winds agents down cleanly as the session's credit runs out and has the orchestrator write a resume memo.

newbandrowsguardcommandtoast
★ 1v0.4.3MITupdated 2026-10-09Rctt27/graceful-stop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · graceful-stop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /graceful-stop ⎿ graceful-stop: Phase: armed (thresholds session 90%, weekly 95%, overage budget $2.00, grace 5 calls) ⎿ graceful-stop: Credit: five_hour 31% ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ ⏹ graceful-stop ● ARMED clean stop at 90 % (5h) · 95 % (7d) · overage budget $2.00 · 5 grace calls [ Off ] [ │ │ Session 5h▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 31 % ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ⏹ graceful-stop ● ARMED clean stop at 90 % (5h) · 95 % (7d) · overage budget $2.00 · 5 grace c │ │ Session 5h▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 31 % ╰───────────────────────────────────────────────────────────────────────────────────────────────────
README

graceful-stop

Tested with Claude Code 2.1.295.

A Claude Code mod that stops your agents cleanly before your Pro or Max credit runs out, instead of letting the limit cut them off mid-task. They wrap up, report, and the orchestrator writes a memo you can resume from once your credit is back.

The graceful-stop card

How it works

  1. Armed: the mod watches your 5-hour and 7-day windows.
  2. Stopping: at 90% of the 5-hour window or 95% of the week, no new subagent can start. Running ones get 5 more tool calls to leave things consistent, then hand back a status report.
  3. Overage: if a window hits 100%, the stop can finish on extra usage, up to an estimated $2.
  4. Stopped: the orchestrator turns the reports into GRACEFUL-STOP_<date>.md at the root of your repo, and no more requests go out.

The weekly threshold is higher because the last 10% of a week is about one whole session.

Once your credit is back, Resume (or /gs resume) re-arms the mod and has the orchestrator pick up from the memo. Too early, and it tells you when:

Your credits have not been reset yet. Reset time: today 18:40 (in 2 h 13).

It also works from a new session in the same repo: the mod remembers the last memo, or takes the newest one at the root. claude --resume <session> brings back the conversation too.

The memo and the reports are written in your language. Anything the mod says in the conversation shows up in yellow, so you can tell it apart from the agent's own work.

Examples

  • A long job hits the limit overnight. Four subagents are refactoring a codebase when the 5-hour window reaches 90%. They finish their current step and report back. In the morning you find the memo at the root of the repo, and /gs resume relaunches what was left.
  • You need to step away. /gs stop winds everything down now, with the same memo, instead of waiting for a threshold.
  • You come back in a new session. In the same repo, /gs resume finds the last memo. If your credit isn't back yet, it tells you when it will be.

Commands

/gs is short for /graceful-stop.

Command
/gsShow the card
/gs stopStart the clean stop now
/gs resumeRe-arm and pick up from the memo
/gs on / offArm or disarm for this session

Commands run immediately, even mid-turn. In the panel, click the buttons (fullscreen terminal or desktop app), or press ctrl+x tab then o / r. ctrl+x ctrl+a folds the panel.

Settings

In /config, set the ▸ graceful-stop row to true to show the settings (reopen /config if they don't appear).

SettingDefault
Armed by default at session starttrue (false: each session starts disarmed, /gs on arms it)
Session threshold90%
Weekly threshold95%
Overage budget$2
Grace tool calls5

Install

claude plugin marketplace add Rctt27/graceful-stop
claude plugin install graceful-stop@graceful-stop

Then /reload-plugins. From a local clone: claude --plugin-dir /path/to/graceful-stop.

Requirements: Claude Code with mods (function-hook plugins, early access; tested on 2.1.289 – 2.1.295) and a Pro or Max subscription. Credit percentages only exist on a subscription: with an API key, only /gs stop works. Extra usage is optional; without it, the stop has to fit before 100%.

Good to know

  • The overage budget is an estimate at API prices, not your invoice. The monthly cap you set on claude.ai is the real safety net.
  • Readings come with each model answer, so usage from elsewhere (another terminal, claude.ai) shows up at the next one. Reset times are shown in local time.
  • A tool already running isn't interrupted; the next call is refused.
  • Teammates in their own terminal pane are out of reach; subagents started by the Agent tool are covered.
  • Reports are captured from Claude Code's internal SubagentHandback tool. If that changes, the memo loses them but the rest still works.

What it touches

Nothing leaves your machine: the mod makes no network calls.

  • Reads: the session's credit windows and cost, the list of running subagents, and their status reports. Nothing else from the conversation.
  • Writes: the memo, GRACEFUL-STOP_<date>.md at the root of your repo, during a stop only. It copies the subagents' reports into it.
  • Keeps in Claude Code's plugin store, on your machine: the last credit reading and when it was taken, the last memo's path, and the memos already resumed from.
  • Acts on the session: it adds its notes to the conversation, submits the resume prompt when you resume, and refuses new subagents, tool calls past the grace calls, and requests once stopped.

Troubleshooting

  • No card above the prompt: it's folded. ctrl+x ctrl+a unfolds it, and /gs shows the card anyway.
  • "No credit reading yet": the percentages come with the first model answer. With an API key there are none, so only /gs stop works.
  • A setting seems ignored: an out-of-range value falls back to its default, and the session start says which one.
  • Resume finds no memo: it looks at the root of the current repo and skips memos already resumed from. You can still ask Claude to read a memo yourself.
  • A change doesn't show after an update: run /reload-plugins.

Support

Bugs and questions: GitHub issues. Security issues: report them privately from the repo's Security tab.

Development

claude plugin validate .
claude plugin test .

Load with --plugin-dir while editing: each save reloads the mod.

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 1148 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, ResolveInput, SessionRateLimit } from 'claude-code'
3
4import type { GracefulStopAgent, GracefulStopCredit, GracefulStopStatus } from '../types'
5
6const NAME = 'graceful-stop'
7// A short name for the same command: /gs.
8const ALIAS = 'gs'
9const COMMANDS = [NAME, ALIAS]
10const TAG = `[${NAME}]`
11const FALLBACK_MARK = `<!-- ${NAME}:fallback -->`
12const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting'])
13// Main turns the orchestrator gets, once every agent is done, to write the
14// memo before the mod stops the session anyway.
15const MAX_IDLE_TURNS = 2
16// The tool a subagent hands its final report back with; never counted or cut off.
17const HANDBACK_TOOL = 'SubagentHandback'
18// The /config row that folds the mod's other rows away, like a chevron.
19const TOGGLE_KEY = `${NAME}.showSettings`
20// `$.store` keys, kept between sessions: the last credit reading and the last
21// memo, so a fresh session can still tell when to resume and from what.
22const CREDIT_KEY = 'credit'
23const CREDIT_AT_KEY = 'creditAt'
24const MEMO_KEY = 'memoPath'
25// The memos already resumed from, so no resume runs one twice; the last few.
26const RESUMED_KEY = 'resumed'
27const MAX_RESUMED = 50
28// Memos written before the mod was renamed keep the old prefix.
29const MEMO_FILE = /^(GRACEFUL-STOP|CLEAN-END-OF-SESSION)_.*\.md$/
30const SUBCOMMANDS = ['status', 'stop', 'resume', 'on', 'off']
31
32// The numeric settings: a value that is not a number within its bounds gives
33// way to the default, and the session start says so.
34const SETTINGS = {
35  sessionThreshold: { label: 'Session trigger threshold', fallback: 90, min: 1, max: 100 },
36  weeklyThreshold: { label: 'Weekly trigger threshold', fallback: 95, min: 1, max: 100 },
37  overageBudgetUsd: { label: 'Overage budget', fallback: 2, min: 0, max: Infinity },
38  graceToolCalls: { label: 'Grace tool calls', fallback: 5, min: 0, max: Infinity },
39}
40type SettingKey = keyof typeof SETTINGS
41
42const ARMED: GracefulStopStatus = {
43  phase: 'armed',
44  trigger: null,
45  resetsAt: null,
46  baselineUsd: null,
47  spentUsd: 0,
48  warned: {},
49  memoPath: null,
50  agents: [],
51  idleTurns: 0,
52  fallbackHash: null,
53}
54
55const OFF: GracefulStopStatus = { ...ARMED, phase: 'off' }
56
57const status = atom({ plugin: 'graceful-stop', key: 'status' } as const, ARMED)
58const credit = atom({ plugin: 'graceful-stop', key: 'credit' } as const, null)
59const minute = atom({ plugin: 'graceful-stop', key: 'minute' } as const, 0)
60const started = atom({ plugin: 'graceful-stop', key: 'started' } as const, false)
61
62type Engine = EngineInterface
63type Config = {
64  /** Trigger for the 5-hour session window, in percent. */
65  sessionThreshold: number
66  /** Trigger for the 7-day window: its last few percent are about one whole session. */
67  weeklyThreshold: number
68  budgetUsd: number
69  graceCalls: number
70  /** Whether a new session starts armed, else off until the person arms it. */
71  armAtStart: boolean
72  /** The labels of the settings whose value was not valid, replaced by their default. */
73  invalid: string[]
74}
75
76// A numeric setting, or null when it is not a number within its bounds; left
77// blank, its default.
78const settingOf = (raw: unknown, key: SettingKey) => {
79  const { fallback, min, max } = SETTINGS[key]
80  if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '')) return fallback
81  const n = typeof raw === 'number' || typeof raw === 'string' ? Number(raw) : NaN
82
83  return Number.isFinite(n) && n >= min && n <= max ? n : null
84}
85
86const configOf = (options: Record<string, unknown>): Config => {
87  const keys = Object.keys(SETTINGS) as SettingKey[]
88  const read = Object.fromEntries(keys.map(k => [k, settingOf(options[k], k)])) as Record<SettingKey, number | null>
89  const value = (k: SettingKey) => read[k] ?? SETTINGS[k].fallback
90
91  return {
92    sessionThreshold: value('sessionThreshold'),
93    weeklyThreshold: value('weeklyThreshold'),
94    budgetUsd: value('overageBudgetUsd'),
95    graceCalls: Math.floor(value('graceToolCalls')),
96    armAtStart: options.armAtStart !== false,
97    invalid: keys.filter(k => read[k] === null).map(k => SETTINGS[k].label),
98  }
99}
100
101// Whether the main loop is in a turn; a reload forgets it, which only means
102// the next clean stop may judge the session idle and write no memo.
103let isMainBusy = false
104
105const isWatching = (s: GracefulStopStatus) => s.phase === 'stopping' || s.phase === 'overage'
106const isHalted = (s: GracefulStopStatus) => s.phase === 'stopped' || s.phase === 'braked'
107
108const usd = (n: number) => `$${n.toFixed(2)}`
109
110const peakOf = (windows: readonly SessionRateLimit[]) =>
111  windows.reduce<SessionRateLimit | null>(
112    (top, w) => (top === null || w.percentUsed > top.percentUsed ? w : top),
113    null,
114  )
115
116// The weekly window has its own threshold; the session window and any other
117// (a gateway's spend limit) take the session one.
118const thresholdOf = (cfg: Config, kind: string) =>
119  kind === 'seven_day' ? cfg.weeklyThreshold : cfg.sessionThreshold
120
121// The window past its threshold by the widest margin, or null.
122const crossedWindow = (cfg: Config, windows: readonly SessionRateLimit[]) =>
123  windows
124    .filter(w => w.percentUsed >= thresholdOf(cfg, w.kind))
125    .reduce<SessionRateLimit | null>(
126      (top, w) =>
127        top === null ||
128        w.percentUsed - thresholdOf(cfg, w.kind) > top.percentUsed - thresholdOf(cfg, top.kind)
129          ? w
130          : top,
131      null,
132    )
133
134const thresholdsText = (cfg: Config) =>
135  `session ${cfg.sessionThreshold}%, weekly ${cfg.weeklyThreshold}%`
136
137const describeWindow =(w: SessionRateLimit | null) =>
138  w === null ? 'credit unknown' : `${w.kind} at ${w.percentUsed}%`
139
140// A reading taken before its window reset counts as reset: once the brake
141// holds, no request refreshes it.
142const isPastReset = (w: SessionRateLimit, now: number) =>
143  w.resetsAt !== undefined && Date.parse(w.resetsAt) <= now
144
145// The windows whose reading still holds.
146const currentWindows = (windows: readonly SessionRateLimit[], now: number) =>
147  windows.filter(w => !isPastReset(w, now))
148
149// The windows still over their threshold.
150const blockingWindows = (cfg: Config, windows: readonly SessionRateLimit[], now: number) =>
151  currentWindows(windows, now).filter(w => w.percentUsed >= thresholdOf(cfg, w.kind))
152
153// When the last blocking window resets: NaN when one of them gives no time.
154const lastResetOf = (windows: readonly SessionRateLimit[]) =>
155  Math.max(...windows.map(w => (w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt))))
156
157const pad = (n: number) => String(n).padStart(2, '0')
158const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
159const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
160
161const dayOf = (at: Date, now: Date) => {
162  const startOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
163  const days = Math.round((startOf(at) - startOf(now)) / 86_400_000)
164  if (days === 0) return 'today'
165  if (days === 1) return 'tomorrow'
166
167  return `${DAYS[at.getDay()]} ${at.getDate()} ${MONTHS[at.getMonth()]}`
168}
169
170const remainingOf = (ms: number) => {
171  const minutes = Math.ceil(ms / 60_000)
172  if (minutes < 60) return `in ${minutes} min`
173  if (minutes < 24 * 60) return `in ${Math.floor(minutes / 60)} h ${pad(minutes % 60)}`
174
175  return `in ${Math.floor(minutes / 1440)} d ${Math.floor((minutes % 1440) / 60)} h`
176}
177
178// A time in the person's local time: `today 18:40`.
179const clockOf = (at: number, now: number) => {
180  const date = new Date(at)
181
182  return `${dayOf(date, new Date(now))} ${pad(date.getHours())}:${pad(date.getMinutes())}`
183}
184
185// A reset time in the person's local time: `today 18:40 (in 2 h 13)`.
186const whenText = (at: number, now: number) =>
187  Number.isNaN(at) ? 'at an unknown time' : `${clockOf(at, now)} (${remainingOf(at - now)})`
188
189// A memo's date in the person's local time: `2026-10-09_12h05`.
190const stamp = (ms: number) => {
191  const d = new Date(ms)
192
193  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}h${pad(d.getMinutes())}`
194}
195
196// FNV-1a, enough to tell the mod's provisional memo from any other content.
197const hashOf = (text: string) => {
198  let h = 0x811c9dc5
199  for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193) >>> 0
200
201  return h
202}
203
204// How a subagent's run ended, as its memo section says it.
205const ENDED: Record<string, string> = { answer: 'completed', aborted: 'interrupted', error: 'failed', refusal: 'refused' }
206// Ends the mod saw itself, which the agent list may not tell.
207const ENDED_BADLY = new Set(['interrupted', 'failed', 'refused'])
208
209const normalize = (path: string) => path.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
210
211const isWithin = (path: string, dir: string) =>
212  normalize(path) === normalize(dir) || normalize(path).startsWith(`${normalize(dir)}/`)
213
214const handbackText = (input: object) => {
215  const message = (input as { message?: unknown }).message
216
217  return typeof message === 'string' ? message : null
218}
219
220const joinPath = (dir: string, file: string) => {
221  const sep = dir.includes('\\') ? '\\' : '/'
222
223  return dir.replace(/[\\/]+$/, '') + sep + file
224}
225
226const toRecord = (a: AgentInfo): GracefulStopAgent => ({
227  id: a.id,
228  description: a.description,
229  type: a.type,
230  status: a.status,
231  report: null,
232})
233
234const note = (text: string) => ({
235  message: { type: 'user' as const, content: [{ type: 'text' as const, text }] },
236})
237
238const agentWarning = (cfg: Config, trigger: string) =>
239  `${TAG} The Claude session credit is almost used up (${trigger}). Wind down cleanly: start no ` +
240  `new step. You have at most ${cfg.graceCalls} more tool calls to leave files in a consistent ` +
241  `state (finish or revert the change in progress), then your tools will be cut off. Your final ` +
242  `answer must be a status report: 1) what is done; 2) what is half done, with the files involved; ` +
243  `3) the exact next step to resume; 4) anything to watch out for. Write the report in the ` +
244  `language your task asks its results in, else in the language your task is written in, not ` +
245  `in the language of this note.`
246
247const AGENT_CUTOFF =
248  `${TAG} Tools cut off: the session credit is almost used up. Call no more tools. Write your ` +
249  `final answer now as a status report (done / half done with files / next step / watch-outs).`
250
251// Everything the mod says in the conversation is drawn in this colour, under
252// this label, so it never reads as the agent's own work.
253const MOD_COLOR = 'yellow'
254const MOD_LABEL = `⏹  ${NAME}`
255// The panel's frame: a neutral gray that reads on dark and light themes.
256const CARD_BORDER = '#71717a'
257
258const isModText = (text: string) => text.trimStart().startsWith(TAG)
259
260// The message without its tag, or a command's output without the plugin name
261// the engine prints before it.
262const modBody = (text: string) => {
263  const body = text.trimStart()
264  const prefix = [TAG, ...COMMANDS.map(c => `${c}:`)].find(p => body.startsWith(p)) ?? ''
265
266  return body.slice(prefix.length).trim()
267}
268
269const SPAWN_DENIED = `${TAG} Spawn refused: a clean stop is under way, the session credit is almost used up.`
270
271const mainNote = (cfg: Config, trigger: string, agents: number, memoPath: string) =>
272  `${TAG} The Claude session credit has reached ${trigger}. Clean stop started: take on no new ` +
273  `task and spawn no agent (spawning is blocked). ${agents} active subagent(s) were told to wind ` +
274  `down and return a status report. Finish what you are doing as briefly as you can. Write the ` +
275  `resume memo as soon as the last report is in, in that same turn (right away if there is no ` +
276  `agent): before ending any turn, check whether any agent is still running, and never end a ` +
277  `turn just to wait for reports that may already be in. Write it to ${memoPath}, fully ` +
278  `replacing the provisional file there: overall goal, each agent's state (done / partial / next ` +
279  `step / files), decisions made, and how to resume. Write the memo in the language the user ` +
280  `writes in (that of their latest messages if they switched), not in the language of this ` +
281  `note. Then stop. Past 100% of the credit, paid ` +
282  `overage of at most ${usd(cfg.budgetUsd)} is allowed for this clean stop only: be concise.`
283
284// How to pick the work up again; `back` is when the credit is back
285// (whenText), null when it already is.
286const resumeHint = (back: string | null) =>
287  back === null
288    ? `The credit is below the thresholds: /${NAME} resume picks the work up.`
289    : `Credit back ${back}: /${NAME} resume then picks the work up.`
290
291const haltText = (cfg: Config, s: GracefulStopStatus, back: string | null) => {
292  const why =
293    s.phase === 'braked'
294      ? `Overage budget used up (${usd(s.spentUsd)} of ${usd(cfg.budgetUsd)}).`
295      : `Session stopped cleanly (${s.trigger ?? 'threshold reached'}).`
296  const memo = s.memoPath === null ? '' : ` Resume memo: ${s.memoPath}.`
297
298  return `${TAG} ${why}${memo} No request was sent to the model. ${resumeHint(back)}`
299}
300
301const resumePrompt = (memoPath: string) =>
302  `${TAG} The credit is back: resume the work a clean stop interrupted. Read the resume memo at ` +
303  `${memoPath} first, then carry on from it: relaunch each unfinished task from its next step, ` +
304  `as subagents where the memo had them, and check the files it lists as half done before ` +
305  `building on them. Answer in the language the memo is written in, not in the language of ` +
306  `this note.`
307
308const notReset = (back: string) => `Your credits have not been reset yet. Reset time: ${back}.`
309
310async function activeAgents($: Engine) {
311  return (await $.agent.list()).filter(a => ACTIVE_AGENT.has(a.status))
312}
313
314const agentSection = (a: GracefulStopAgent) => [
315  `### ${a.description}`,
316  '',
317  `- Type: ${a.type}`,
318  `- Status: ${a.status}`,
319  `- Id: \`${a.id}\``,
320  '',
321  a.report === null ? '_No status report received._' : a.report.trim(),
322  '',
323]
324
325async function fallbackMemo($: Engine, s: GracefulStopStatus) {
326  const sessionId = await $.session.id()
327  const now = new Date(await $.clock.now()).toISOString()
328
329  return [
330    FALLBACK_MARK,
331    '# Resume memo (provisional)',
332    '',
333    `Written by the ${NAME} mod at ${now}. The orchestrator was to replace this file with a ` +
334      `full memo; if it is still here, the clean stop did not run to the end. Each agent's own ` +
335      `status report is copied below as it came in.`,
336    '',
337    `- Trigger: ${s.trigger ?? 'unknown'}`,
338    `- Credit resets at: ${s.resetsAt ?? 'unknown'}`,
339    `- Stop state: ${s.phase}${s.phase === 'braked' ? ` (overage ${usd(s.spentUsd)})` : ''}`,
340    `- Session: \`${sessionId}\``,
341    '',
342    '## Agents covered by the stop',
343    '',
344    ...(s.agents.length === 0 ? ['No subagent was running.', ''] : s.agents.flatMap(agentSection)),
345    '## To resume',
346    '',
347    `1. \`claude --resume ${sessionId}\` brings back the conversation and the agents' status reports.`,
348    '2. Otherwise, relaunch each task above from its report.',
349    '',
350  ].join('\n')
351}
352
353// Whether the memo file holds anything but the provisional memo the mod last
354// wrote, even one that kept its first line.
355async function isMemoReplaced($: Engine, s: GracefulStopStatus) {
356  if (s.memoPath === null || !(await $.fs.exists(s.memoPath))) return false
357  const current = await $.fs.read(s.memoPath)
358  if (typeof current !== 'string') return false
359  const written = s.fallbackHash ?? null
360
361  return written === null ? !current.startsWith(FALLBACK_MARK) : hashOf(current) !== written
362}
363
364// Writes the mod's own record of the stop, unless the orchestrator already
365// replaced it with the real memo; the agents' statuses are refreshed first.
366async function writeFallback($: Engine, s: GracefulStopStatus) {
367  if (s.memoPath === null || (await isMemoReplaced($, s))) return
368  const path = s.memoPath
369  const live = new Map((await $.agent.list()).map(a => [a.id, a.status as string]))
370  const agents = s.agents.map(a => ({
371    ...a,
372    status: ENDED_BADLY.has(a.status) ? a.status : (live.get(a.id) ?? a.status),
373  }))
374  const text = await fallbackMemo($, { ...s, agents })
375  await $.fs.write(path, text)
376  const hash = hashOf(text)
377  await update($, status, cur => (cur.memoPath === path ? { ...cur, fallbackHash: hash } : cur))
378}
379
380async function warnAgent($: Engine, cfg: Config, agentId: string, trigger: string) {
381  const text = agentWarning(cfg, trigger)
382  const appended = await $.session.append({ ...note(text), agentId }).catch(() => null)
383  if (appended === null || 'deny' in appended) {
384    await $.session.send({ to: { agentId }, text }).catch(() => undefined)
385  }
386}
387
388async function warnMain($: Engine, text: string) {
389  const appended = await $.session.append(note(text)).catch(() => null)
390  if (appended === null || 'deny' in appended) {
391    await $.session.send({ to: { sessionId: await $.session.id() }, text }).catch(() => undefined)
392  }
393}
394
395// The root of the repository the session works in, else its project root.
396async function memoDir($: Engine) {
397  const root = await $.session.root()
398  const repo = await $.session.repo()
399
400  return repo !== null && isWithin(root, repo.root) ? repo.root : root
401}
402
403// When the credit is back, as whenText says it, or null when it already is.
404const backOf = (cfg: Config, windows: readonly SessionRateLimit[], now: number) => {
405  const blocking = blockingWindows(cfg, windows, now)
406
407  return blocking.length === 0 ? null : whenText(lastResetOf(blocking), now)
408}
409
410async function creditBack($: Engine, cfg: Config) {
411  const { reading } = await readCredit($)
412
413  return backOf(cfg, reading?.windows ?? [], await $.clock.now())
414}
415
416async function haltMessage($: Engine, cfg: Config, s: GracefulStopStatus) {
417  return haltText(cfg, s, await creditBack($, cfg))
418}
419
420async function resumedMemos($: Engine) {
421  const kept = await $.store.get(RESUMED_KEY).catch(() => undefined)
422
423  return Array.isArray(kept) ? kept.filter((p): p is string => typeof p === 'string') : []
424}
425
426// The memo to resume from: this session's, the last one kept, else the newest
427// at the root the memos go to; never one of another project (the store is
428// shared by every project) nor one already resumed from.
429async function findMemo($: Engine, s: GracefulStopStatus) {
430  const dir = await memoDir($)
431  const resumed = new Set((await resumedMemos($)).map(normalize))
432  const isFree = (path: string) => isWithin(path, dir) && !resumed.has(normalize(path))
433  const kept = await $.store.get(MEMO_KEY).catch(() => undefined)
434  for (const path of [s.memoPath, typeof kept === 'string' ? kept : null]) {
435    if (path !== null && isFree(path) && (await $.fs.exists(path))) return path
436  }
437  const newest = (await $.fs.list(dir).catch(() => []))
438    .filter(f => f.kind === 'file' && MEMO_FILE.test(f.name) && isFree(joinPath(dir, f.name)))
439    .sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))[0]
440
441  return newest === undefined ? null : joinPath(dir, newest.name)
442}
443
444// A free path for a new memo: two stops in the same minute get two files.
445async function freshMemoPath($: Engine) {
446  const dir = await memoDir($)
447  const base = `GRACEFUL-STOP_${stamp(await $.clock.now())}`
448  for (let n = 1; ; n += 1) {
449    const path = joinPath(dir, n === 1 ? `${base}.md` : `${base}-${n}.md`)
450    if (!(await $.fs.exists(path))) return path
451  }
452}
453
454// Hands the orchestrator its resume prompt as a turn of its own, once idle;
455// once it went through, the memo is never resumed from again.
456async function submitResume($: Engine, memoPath: string) {
457  const sent = await $.prompt.submit({ text: resumePrompt(memoPath) }).catch(() => null)
458  if (sent === null || sent.drop !== undefined) {
459    $.ui.toast(`${NAME}: the resume prompt did not go through${sent?.drop ? ` (${sent.drop})` : ''}.`)
460    return
461  }
462  const resumed = [...(await resumedMemos($)), memoPath].slice(-MAX_RESUMED)
463  await $.store.set(RESUMED_KEY, resumed).catch(() => undefined)
464}
465
466// `/graceful-stop resume`: re-arms and relaunches the work from the
467// memo, or says why not.
468async function resume($: Engine, cfg: Config) {
469  const s = await read($, status)
470  if (isWatching(s)) return `A clean stop is under way: resume once it has ended.`
471  const back = await creditBack($, cfg)
472  if (back !== null) return notReset(back)
473  const memoPath = await findMemo($, s)
474  if (memoPath === null) {
475    return `No resume memo found in ${await memoDir($)}. /${NAME} on re-arms without resuming.`
476  }
477
478  await update($, status, () => ARMED)
479  // A command may not submit a prompt itself (it would wait on its own run):
480  // a timer submits it once the command has answered.
481  $.clock.after(1, () => submitResume($, memoPath))
482
483  return `Re-armed at ${thresholdsText(cfg)}. Resuming from ${memoPath}.`
484}
485
486async function startStop($: Engine, cfg: Config, top: SessionRateLimit | null, trigger: string) {
487  if ((await read($, status)).phase !== 'armed') return
488
489  const agents = await activeAgents($)
490  const hasWork = agents.length > 0 || isMainBusy
491  const memoPath = hasWork ? await freshMemoPath($) : null
492  const stop: GracefulStopStatus = {
493    ...ARMED,
494    phase: hasWork ? 'stopping' : 'stopped',
495    trigger,
496    resetsAt: top?.resetsAt ?? null,
497    warned: Object.fromEntries(agents.map(a => [a.id, 0])),
498    memoPath,
499    agents: agents.map(toRecord),
500  }
501  await update($, status, () => stop)
502
503  if (memoPath === null) {
504    $.ui.toast(
505      `${NAME}: ${trigger}, nothing running. No more requests will leave. ` +
506        resumeHint(await creditBack($, cfg)),
507    )
508    return
509  }
510
511  await writeFallback($, stop)
512  await $.store.set(MEMO_KEY, memoPath).catch(() => undefined)
513  await Promise.all(agents.map(a => warnAgent($, cfg, a.id, trigger)))
514  await warnMain($, mainNote(cfg, trigger, agents.length, memoPath))
515  $.ui.toast(`${NAME}: ${trigger}, clean stop of ${agents.length} agent(s) started.`)
516}
517
518// Keeps an agent the stop did not see at its start (spawned just before it).
519async function adoptAgent($: Engine, id: string) {
520  const found = (await $.agent.list()).find(a => a.id === id)
521  const record: GracefulStopAgent = found === undefined
522    ? { id, description: 'unknown task', type: 'unknown', status: 'running', report: null }
523    : toRecord(found)
524  await update($, status, s =>
525    s.agents.some(a => a.id === id) ? s : { ...s, agents: [...s.agents, record] },
526  )
527}
528
529// Copies a subagent's status report into the memo: its hand-back message, or
530// its final answer when it handed nothing back; an empty answer keeps the
531// report already captured.
532async function recordReport($: Engine, id: string, report: string, agentStatus: string) {
533  if (!(await read($, status)).agents.some(a => a.id === id)) await adoptAgent($, id)
534  const text = report.trim() === '' ? null : report
535  await update($, status, s => ({
536    ...s,
537    agents: s.agents.map(a =>
538      a.id === id ? { ...a, status: agentStatus, report: text ?? a.report } : a,
539    ),
540  }))
541  await writeFallback($, await read($, status))
542}
543
544// A main turn ended with every agent done: the stop is over once the memo is
545// written, or after MAX_IDLE_TURNS turns that did not write it.
546async function settleMainTurn($: Engine, cfg: Config, s: GracefulStopStatus) {
547  const isWritten = s.memoPath === null || (await isMemoReplaced($, s))
548  if (isWritten || s.idleTurns + 1 >= MAX_IDLE_TURNS) {
549    await halt($, cfg, 'stopped')
550    return
551  }
552  await update($, status, cur => (isWatching(cur) ? { ...cur, idleTurns: cur.idleTurns + 1 } : cur))
553}
554
555async function halt($: Engine, cfg: Config, phase: 'stopped' | 'braked') {
556  await update($, status, s => (isWatching(s) ? { ...s, phase } : s))
557  const s = await read($, status)
558  if (s.phase !== phase) return
559  await writeFallback($, s)
560  const back = await creditBack($, cfg)
561  $.ui.toast(
562    (phase === 'braked'
563      ? `${NAME}: overage budget used up, no more requests leave.`
564      : `${NAME}: clean stop complete.`) + ` ${resumeHint(back)}`,
565  )
566}
567
568async function checkBudget($: Engine, cfg: Config, costUsd: number | undefined) {
569  const s = await read($, status)
570  if (s.phase !== 'overage' || s.baselineUsd === null || costUsd === undefined) return
571  const spent = Math.max(0, costUsd - s.baselineUsd)
572  await update($, status, cur => (cur.phase === 'overage' ? { ...cur, spentUsd: spent } : cur))
573  if (spent >= cfg.budgetUsd) await halt($, cfg, 'braked')
574}
575
576async function statusText($: Engine, cfg: Config) {
577  const s = await read($, status)
578  const usage = await $.session.usage()
579  const windows = usage.rateLimits.map(w => `${w.kind} ${w.percentUsed}%`).join(', ') || 'no reading yet'
580  const warned = Object.entries(s.warned)
581  const back = await creditBack($, cfg)
582  const lines = [
583    `Phase: ${s.phase} (thresholds ${thresholdsText(cfg)}, overage budget ${usd(cfg.budgetUsd)}, grace ${cfg.graceCalls} calls)`,
584    `Credit: ${windows}`,
585    back === null ? null : `Credit back: ${back}`,
586    s.trigger === null ? null : `Trigger: ${s.trigger}`,
587    s.phase === 'overage' || s.phase === 'braked' ? `Estimated overage: ${usd(s.spentUsd)}` : null,
588    warned.length === 0 ? null : `Agents warned: ${warned.map(([id, n]) => `${id} (${n} calls)`).join(', ')}`,
589    s.memoPath === null ? null : `Memo: ${s.memoPath}`,
590  ]
591
592  return lines.filter(l => l !== null).join('\n')
593}
594
595// The band above the prompt: a card with a status badge and the buttons, then
596// one gauge per credit window.
597
598const WINDOW_NAMES: Record<string, string> = { five_hour: 'Session 5h', seven_day: 'Week 7d', spend_limit: 'Spend' }
599const windowName = (kind: string) => WINDOW_NAMES[kind] ?? kind
600const windowRank = (kind: string) => {
601  const rank = Object.keys(WINDOW_NAMES).indexOf(kind)
602
603  return rank < 0 ? Number.MAX_SAFE_INTEGER : rank
604}
605
606// The card's left column: the icon, then the mod's name in the header and
607// each window's name under it, right-aligned on the mod's name; then a gap.
608// The badge and the gauges start at the same column whatever width the
609// terminal gives the icon.
610const ICON_CELLS = 3
611const NAME_CELLS = NAME.length
612const LEFT_CELLS = ICON_CELLS + NAME_CELLS + 2
613// Cells after the gauge for `  100 %`; the card's border and padding take 4.
614const PERCENT_CELLS = 7
615const CARD_CELLS = 4
616
617// Colors as 0xRRGGBB: the gauge fades from green to amber to red as it nears
618// the window's threshold, over a dark track.
619const GREEN = 0x22c55e
620const AMBER = 0xf59e0b
621const RED = 0xef4444
622const TRACK = 0x3a3a3a
623const MARK = 0xa1a1aa
624const hex = (rgb: number) => `#${rgb.toString(16).padStart(6, '0')}`
625
626const mix = (from: number, to: number, t: number) => {
627  const at = Math.min(1, Math.max(0, t))
628  const channel = (shift: number) => {
629    const a = (from >> shift) & 0xff
630    const b = (to >> shift) & 0xff
631
632    return Math.round(a + (b - a) * at) << shift
633  }
634
635  return channel(16) | channel(8) | channel(0)
636}
637
638// The gauge's color at `percent`: green well below the threshold, amber 15
639// points before it, red at it.
640const gradientAt = (percent: number, threshold: number) => {
641  const greenUntil = threshold - 35
642  const amberAt = threshold - 15
643  if (percent <= greenUntil) return GREEN
644  if (percent <= amberAt) return mix(GREEN, AMBER, (percent - greenUntil) / (amberAt - greenUntil))
645
646  return mix(AMBER, RED, (percent - amberAt) / (threshold - amberAt))
647}
648
649// A Raster color for the terminal's own background.
650const DEFAULT_BG = 0x01000000
651// The lower seven eighths of a cell: the eighth above stays the terminal's
652// background, a thin line between two gauges.
653const BAR = '▇'
654
655type Cell = { glyph: string; fg: number; bg: number }
656
657// The gauge's cells, filled to the eighth of a cell: the cell where the fill
658// ends blends its color with the track's. An empty cell is the track, and
659// the threshold a lighter cell of it. No cell paints a background of its own:
660// one would fill the line between the gauges, and the terminal may carry it
661// over to the cells after it.
662const gaugeCells = (percent: number, threshold: number, width: number): Cell[] => {
663  const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width * 8)
664  const mark = Math.min(width - 1, Math.round((threshold / 100) * width))
665
666  return Array.from({ length: width }, (_, i) => {
667    const fill = eighths - i * 8
668    const color = gradientAt(((i + 0.5) / width) * 100, threshold)
669    if (fill >= 8) return { glyph: BAR, fg: color, bg: DEFAULT_BG }
670    if (fill > 0) return { glyph: BAR, fg: mix(TRACK, color, fill / 8), bg: DEFAULT_BG }
671    if (i === mark) return { glyph: BAR, fg: MARK, bg: DEFAULT_BG }
672
673    return { glyph: BAR, fg: TRACK, bg: DEFAULT_BG }
674  })
675}
676
677const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
678
679const toBase64 = (bytes: Uint8Array) => {
680  let out = ''
681  for (let i = 0; i < bytes.length; i += 3) {
682    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
683    out += (BASE64[(n >> 18) & 63] ?? '') + (BASE64[(n >> 12) & 63] ?? '')
684    out += i + 1 < bytes.length ? (BASE64[(n >> 6) & 63] ?? '') : '='
685    out += i + 2 < bytes.length ? (BASE64[n & 63] ?? '') : '='
686  }
687
688  return out
689}
690
691// A Raster's cells: little-endian u32 triplets [code point, foreground, background].
692const rasterCells = (cells: readonly Cell[]) => {
693  const words = new Uint32Array(cells.length * 3)
694  cells.forEach((c, i) => words.set([c.glyph.codePointAt(0) ?? 0x20, c.fg, c.bg], i * 3))
695
696  return toBase64(new Uint8Array(words.buffer))
697}
698
699type Run = { text: string; fg: number; bg: number }
700
701// The same cells as text runs, for a surface without Raster.
702const textRuns = (cells: readonly Cell[]) =>
703  cells.reduce<Run[]>((runs, c) => {
704    const last = runs[runs.length - 1]
705    if (last !== undefined && last.fg === c.fg && last.bg === c.bg) last.text += c.glyph
706    else runs.push({ text: c.glyph, fg: c.fg, bg: c.bg })
707
708    return runs
709  }, [])
710
711type Gauge = { name: string; cells: Cell[]; color: string; percent: string; tail: string }
712
713// Each window as a gauge sized to the card; a reading whose reset time has
714// passed is drawn empty, as the next answer will read it.
715const gaugesOf = (cfg: Config, windows: readonly SessionRateLimit[], columns: number, now: number): Gauge[] =>
716  [...windows]
717    .sort((a, b) => windowRank(a.kind) - windowRank(b.kind))
718    .map(w => {
719      const threshold = thresholdOf(cfg, w.kind)
720      const resetsAt = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
721      const isReset = resetsAt <= now
722      const percent = isReset ? 0 : w.percentUsed
723      const said = isReset
724        ? `↻ reset since ${clockOf(resetsAt, now)}`
725        : Number.isNaN(resetsAt) ? '' : `↻ ${clockOf(resetsAt, now)} · ${remainingOf(resetsAt - now)}`
726      const room = columns - CARD_CELLS - LEFT_CELLS - PERCENT_CELLS
727      const width = Math.max(1, Math.min(gaugeSpan(cfg), room))
728      const tail = said === '' || room - width < said.length + 3 ? '' : `   ${said}`
729
730      return {
731        name: windowName(w.kind),
732        cells: gaugeCells(percent, threshold, width),
733        color: hex(gradientAt(percent, threshold)),
734        percent: `${String(Math.round(percent)).padStart(5)} %`,
735        tail,
736      }
737    })
738
739// The credit reading, the one source of the card, the brake and resume: the
740// engine's, else the one the last measure wrote, else the last one kept, which
741// the band says is old. Reading the atom also redraws the band on a measure.
742async function readCredit($: Engine): Promise<{ reading: GracefulStopCredit | null; isKept: boolean }> {
743  const measured = await read($, credit)
744  const live = (await $.session.usage()).rateLimits
745  if (live.length > 0) {
746    return { reading: { windows: [...live], at: measured?.at ?? (await $.clock.now()) }, isKept: false }
747  }
748  if (measured !== null) return { reading: measured, isKept: false }
749  const windows = await $.store.get(CREDIT_KEY).catch(() => undefined)
750  const at = await $.store.get(CREDIT_AT_KEY).catch(() => undefined)
751  if (!Array.isArray(windows) || windows.length === 0) return { reading: null, isKept: false }
752
753  return { reading: { windows: windows as SessionRateLimit[], at: typeof at === 'number' ? at : NaN }, isKept: true }
754}
755
756// The status badge: its word and colors, by phase.
757const BADGES: Record<GracefulStopStatus['phase'], { word: string; fg: string; bg: string }> = {
758  armed: { word: 'ARMED', fg: '#86efac', bg: '#14532d' },
759  off: { word: 'OFF', fg: '#d4d4d8', bg: '#3f3f46' },
760  stopping: { word: 'STOPPING', fg: '#fcd34d', bg: '#78350f' },
761  overage: { word: 'OVERAGE', fg: '#fdba74', bg: '#7c2d12' },
762  stopped: { word: 'STOPPED', fg: '#fca5a5', bg: '#7f1d1d' },
763  braked: { word: 'BRAKED', fg: '#fca5a5', bg: '#7f1d1d' },
764}
765
766// The settings, beside the badge while the mod is armed.
767const thresholdsPart = (cfg: Config) =>
768  `clean stop at ${cfg.sessionThreshold} % (5h) · ${cfg.weeklyThreshold} % (7d)`
769const settingsText = (cfg: Config) =>
770  `${thresholdsPart(cfg)} · overage budget ${usd(cfg.budgetUsd)} · ${cfg.graceCalls} grace calls`
771
772// The gauges span the armed header from the badge's left edge to the `·`
773// after the thresholds: the badge, the space before the detail, the
774// thresholds, then ` ·`.
775const gaugeSpan = (cfg: Config) => badgeText('armed').length + 1 + thresholdsPart(cfg).length + 2
776
777const badgeText = (phase: GracefulStopStatus['phase']) => ` ● ${BADGES[phase].word} `
778
779const phaseDetail = (cfg: Config, s: GracefulStopStatus, back: string | null) => {
780  const whenBack = back === null ? 'credit is back' : `credit back ${back}`
781  switch (s.phase) {
782    case 'armed':
783      return settingsText(cfg)
784    case 'off':
785      return 'off for this session'
786    case 'stopping':
787      return `clean stop under way (${s.trigger}) · ${Object.keys(s.warned).length} agent(s) warned`
788    case 'overage':
789      return `clean stop in overage · ${usd(s.spentUsd)} of ${usd(cfg.budgetUsd)}`
790    case 'braked':
791      return `overage budget used up · ${whenBack}`
792    case 'stopped':
793      return `stopped cleanly · ${whenBack}`
794  }
795}
796
797async function tick($: Engine) {
798  const now = Math.floor((await $.clock.now()) / 60_000)
799  await update($, minute, () => now)
800}
801
802// The band's Off / On button: the same as `/graceful-stop off|on`.
803async function toggle($: Engine) {
804  await update($, status, s => (s.phase === 'off' ? ARMED : OFF))
805}
806
807// The band's Resume button: the command's resume, said in a toast.
808async function pressResume($: Engine, cfg: Config, isWorking: boolean) {
809  const said = isWorking ? 'Claude is working: resume once the turn has ended.' : await resume($, cfg)
810  $.ui.toast(`${NAME}: ${said}`)
811}
812
813type PanelOptions = {
814  /** Cells the card may take. */
815  columns: number
816  /** Whether a model turn is running: Resume waits for it. */
817  isWorking: boolean
818  /** What a command just did, shown under the header; null for the panel. */
819  notice: string | null
820}
821
822// The mod's card, drawn above the prompt and as its command's output: its
823// state, a gauge per credit window, its settings, Off / On and Resume.
824async function drawPanel($: Engine, cfg: Config, e: ResolveInput, o: PanelOptions) {
825  const s = await read($, status)
826  await read($, minute)
827  const { reading, isKept } = await readCredit($)
828  const now = await $.clock.now()
829  const back = backOf(cfg, reading?.windows ?? [], now)
830  const canResume = !isWatching(s) && !o.isWorking && back === null
831  const gauges = gaugesOf(cfg, reading?.windows ?? [], o.columns, now)
832  const badge = BADGES[s.phase]
833
834  const ui = $.ui.resolve(e)
835  const { Box, Button, Text } = ui
836  // Raster paints each cell's colors: the terminal's alone (another surface's
837  // table may hold it as a fragment that draws nothing).
838  const Raster = e.surface === 'terminal' && 'Raster' in ui ? ui.Raster : null
839
840  return (
841    <Box flexDirection="column" borderStyle="round" borderColor={CARD_BORDER} paddingX={1}>
842      <Box flexDirection="row" justifyContent="space-between" gap={2}>
843        <Box flexDirection="row" flexShrink={1}>
844          <Box width={ICON_CELLS} flexShrink={0}>
845            <Text color={MOD_COLOR} bold>⏹</Text>
846          </Box>
847          <Box width={LEFT_CELLS - ICON_CELLS} flexShrink={0}>
848            <Text color={MOD_COLOR} bold>{NAME}</Text>
849          </Box>
850          <Text color={badge.fg} backgroundColor={badge.bg} bold>{badgeText(s.phase)}</Text>
851          <Text dimColor wrap="truncate">{` ${phaseDetail(cfg, s, isHalted(s) ? back : null)}`}</Text>
852        </Box>
853        <Box flexDirection="row" flexShrink={0} gap={1}>
854          <Button key="toggle" hotkey="o" onPress={() => toggle($)}>
855            {s.phase === 'off' ? 'On' : 'Off'}
856          </Button>
857          <Button
858            key="resume"
859            hotkey="r"
860            variant={canResume ? 'primary' : 'secondary'}
861            dimColor={!canResume}
862            onPress={() => pressResume($, cfg, o.isWorking)}
863          >
864            Resume
865          </Button>
866        </Box>
867      </Box>
868      {o.notice !== null && (
869        <Text key="notice" wrap="wrap">{`› ${o.notice}`}</Text>
870      )}
871      <Box height={1} />
872      {gauges.map(g => (
873        <Box key={`gauge-${g.name.trim()}`} flexDirection="row">
874          <Box width={ICON_CELLS} flexShrink={0} />
875          <Box width={NAME_CELLS} flexShrink={0} justifyContent="flex-end">
876            <Text bold>{g.name}</Text>
877          </Box>
878          <Box width={LEFT_CELLS - ICON_CELLS - NAME_CELLS} flexShrink={0} />
879          {Raster !== null ? (
880            <Raster key={`bar-${g.name.trim()}`} columns={g.cells.length} rows={1} cells={rasterCells(g.cells)} />
881          ) : (
882            textRuns(g.cells).map((r, i) => (
883              <Text key={`run-${i}`} color={hex(r.fg)} backgroundColor={r.bg === DEFAULT_BG ? undefined : hex(r.bg)}>
884                {r.text}
885              </Text>
886            ))
887          )}
888          <Text color={g.color} bold>{g.percent}</Text>
889          <Text dimColor wrap="truncate">{g.tail}</Text>
890        </Box>
891      ))}
892      {reading === null && (
893        <Text dimColor wrap="truncate">No credit reading yet: it comes with the first answer.</Text>
894      )}
895      {isKept && reading !== null && (
896        <Text dimColor wrap="truncate">
897          {`Last reading ${Number.isNaN(reading.at) ? 'from an earlier session' : `at ${clockOf(reading.at, now)}`}: it refreshes with the first answer.`}
898        </Text>
899      )}
900      {s.memoPath !== null && s.phase !== 'armed' && s.phase !== 'off' && (
901        <Text dimColor wrap="truncate">{`Memo  ${s.memoPath}`}</Text>
902      )}
903    </Box>
904  )
905}
906
907// `/graceful-stop <args>` and `/gs <args>`.
908async function runCommand($: Engine, cfg: Config, args: string) {
909  const arg = args.trim().toLowerCase() || 'status'
910
911  if (arg === 'stop') {
912    const top = peakOf(currentWindows((await $.session.usage()).rateLimits, await $.clock.now()))
913    await update($, status, s => (s.phase === 'off' ? ARMED : s))
914    await startStop($, cfg, top, `manual (${describeWindow(top)})`)
915    const s = await read($, status)
916
917    return { text: `Clean stop: ${s.phase}${s.memoPath === null ? '' : `, memo ${s.memoPath}`}.` }
918  }
919  if (arg === 'resume') {
920    return { text: await resume($, cfg) }
921  }
922  if (arg === 'on') {
923    await update($, status, () => ARMED)
924
925    return { text: `Re-armed: the clean stop will start at ${thresholdsText(cfg)}.` }
926  }
927  if (arg === 'off') {
928    await update($, status, () => OFF)
929
930    return { text: `Off for this session. /${NAME} on to re-arm.` }
931  }
932  if (arg !== 'status') {
933    return { text: `Unknown subcommand "${args.trim()}". Use one of: ${SUBCOMMANDS.join(', ')}.` }
934  }
935
936  return { text: await statusText($, cfg) }
937}
938
939// Counts a subagent's tool call after its warning; the decision rests on the
940// count the update wrote, so parallel calls neither pass the grace calls nor
941// warn twice.
942async function countCall($: Engine, cfg: Config, id: string, trigger: string) {
943  const seen = { before: undefined as number | undefined }
944  await update($, status, cur => {
945    seen.before = cur.warned[id]
946
947    return { ...cur, warned: { ...cur.warned, [id]: (cur.warned[id] ?? 0) + 1 } }
948  })
949  if (seen.before === undefined) {
950    await adoptAgent($, id)
951    await warnAgent($, cfg, id, trigger)
952  }
953
954  return (seen.before ?? 0) < cfg.graceCalls
955}
956
957export const register: Register = (on, options) => {
958  const cfg = configOf(options)
959  const isOpen = options.showSettings === true
960
961  on('session.start', async ($, e, next) => {
962    await $.command.register({
963      name: NAME,
964      description: 'Wind agents down cleanly before the session credit runs out',
965      argumentHint: '[status|stop|resume|on|off]',
966      immediate: true,
967    })
968    await $.command.register({
969      name: ALIAS,
970      description: `Short for /${NAME}`,
971      argumentHint: '[status|stop|resume|on|off]',
972      immediate: true,
973    })
974    // A change of the toggle reloads the module: redraw the rows it folds.
975    $.ui.invalidate('config.describe')
976    // A reload starts the session over for the module, not for the person.
977    if (!(await read($, started))) {
978      await update($, started, () => true)
979      if (!cfg.armAtStart) await update($, status, s => (s.phase === 'armed' ? OFF : s))
980    }
981    if (cfg.invalid.length > 0) {
982      $.ui.toast(`${NAME}: not a valid value in /config, the default applies: ${cfg.invalid.join(', ')}.`)
983    }
984    await tick($)
985    $.clock.every(60_000, () => void tick($))
986
987    return next(e)
988  })
989
990  on('config.describe', async ($, e, next) => {
991    if (!e.key.startsWith(`${NAME}.`)) return next(e)
992    const row = await next(e)
993    if (e.key === TOGGLE_KEY) {
994      return {
995        ...row,
996        label: `${isOpen ? '▾' : '▸'} ${NAME}`,
997        description: isOpen ? `Hide the ${NAME} settings.` : `Show the ${NAME} settings.`,
998      }
999    }
1000
1001    return isOpen ? row : { ...row, isHidden: true }
1002  })
1003
1004  on('session.measure', async ($, e, next) => {
1005    const at = await $.clock.now()
1006    // A window whose reset time has passed no longer counts, whatever it read.
1007    const current = currentWindows(e.rateLimits, at)
1008    const top = peakOf(current)
1009    const crossed = crossedWindow(cfg, current)
1010    if (e.changed.includes('rateLimits') && e.rateLimits.length > 0) {
1011      await update($, credit, () => ({ windows: [...e.rateLimits], at }))
1012      await $.store.set(CREDIT_KEY, e.rateLimits).catch(() => undefined)
1013      await $.store.set(CREDIT_AT_KEY, at).catch(() => undefined)
1014    }
1015
1016    if (crossed !== null) {
1017      await startStop($, cfg, crossed, describeWindow(crossed))
1018    }
1019    if (top !== null && top.percentUsed >= 100) {
1020      await update($, status, (s): GracefulStopStatus =>
1021        s.phase === 'stopping' ? { ...s, phase: 'overage', baselineUsd: e.cost?.usd ?? 0 } : s,
1022      )
1023    }
1024    await checkBudget($, cfg, e.cost?.usd)
1025
1026    return next(e)
1027  })
1028
1029  on('turn.start', (_$, e, next) => {
1030    isMainBusy = true
1031
1032    return next(e)
1033  })
1034
1035  on('turn.complete', async ($, e, next) => {
1036    if (e.agentId !== undefined) {
1037      if (isWatching(await read($, status))) {
1038        await recordReport($, e.agentId, e.answer, ENDED[e.reason] ?? e.reason)
1039      }
1040
1041      return next(e)
1042    }
1043    isMainBusy = false
1044    const s = await read($, status)
1045    if (isWatching(s) && (await activeAgents($)).length === 0) await settleMainTurn($, cfg, s)
1046
1047    return next(e)
1048  })
1049
1050  on('turn.step', async function* ($, e, next) {
1051    const s = await read($, status)
1052    if (isHalted(s)) {
1053      const text = await haltMessage($, cfg, s)
1054      yield { kind: 'text' as const, index: 0, text }
1055
1056      return {
1057        turnId: e.turnId,
1058        index: e.index,
1059        answer: text,
1060        toolUses: [],
1061        stopReason: 'end_turn' as const,
1062        usage: null,
1063      }
1064    }
1065
1066    const result = yield* next(e)
1067    if (isWatching(await read($, status))) {
1068      await checkBudget($, cfg, (await $.session.usage()).cost?.usd)
1069    }
1070
1071    return result
1072  })
1073
1074  on('agent.spawn', async ($, e, next) => {
1075    const s = await read($, status)
1076
1077    return s.phase === 'armed' || s.phase === 'off' ? next(e) : { deny: SPAWN_DENIED }
1078  })
1079
1080  on('tool.call', async ($, e, next) => {
1081    const s = await read($, status)
1082    if (e.agentId !== undefined && String(e.tool) === HANDBACK_TOOL) {
1083      const report = handbackText(e)
1084      if (isWatching(s) && report !== null) await recordReport($, e.agentId, report, 'reporting')
1085
1086      return next(e)
1087    }
1088    if (isHalted(s)) return { deny: await haltMessage($, cfg, s) }
1089    if (!isWatching(s) || e.agentId === undefined) return next(e)
1090
1091    const isAllowed = await countCall($, cfg, e.agentId, s.trigger ?? 'threshold reached')
1092
1093    return isAllowed ? next(e) : { deny: AGENT_CUTOFF }
1094  })
1095
1096  for (const command of COMMANDS) {
1097    on('command.run', { command }, async ($, e) => runCommand($, cfg, e.args))
1098  }
1099
1100  // The mod's notes to the orchestrator and its agents: user-role rows.
1101  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
1102    if (!isModText(e.props.text)) return next(e)
1103    const { Box, Text } = $.ui.resolve(e)
1104
1105    return (
1106      <Box flexDirection="column" borderStyle="round" borderColor={MOD_COLOR} paddingX={1}>
1107        <Text color={MOD_COLOR} bold>{MOD_LABEL}</Text>
1108        <Text color={MOD_COLOR} wrap="wrap">{modBody(e.props.text)}</Text>
1109      </Box>
1110    )
1111  })
1112
1113  // The brake's answers: they read as a reply, yet no model wrote them.
1114  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
1115    if (!isModText(e.props.text)) return next(e)
1116    const { Box, Text } = $.ui.resolve(e)
1117
1118    return (
1119      <Box flexDirection="column">
1120        <Text color={MOD_COLOR} bold>{MOD_LABEL}</Text>
1121        <Text color={MOD_COLOR} wrap="wrap">{modBody(e.props.text)}</Text>
1122      </Box>
1123    )
1124  })
1125
1126  // The command's output is the mod's card, with what the command did on top;
1127  // the model still reads the text.
1128  for (const command of COMMANDS) {
1129    on('ui.render', { component: 'CommandOutput', props: { command } }, async ($, e, next) => {
1130      if (e.props.isErrored) return next(e)
1131      const isStatus = ['', 'status'].includes(e.props.args.trim().toLowerCase())
1132
1133      return drawPanel($, cfg, e, {
1134        columns: (e.viewport?.columns ?? 100) - 2,
1135        isWorking: isMainBusy,
1136        notice: isStatus ? null : modBody(e.props.text),
1137      })
1138    })
1139  }
1140
1141  // The same card above the prompt.
1142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1143    if (e.props.hasSurvey) return next(e)
1144
1145    return drawPanel($, cfg, e, { columns: e.props.bodyColumns, isWorking: e.props.isWorking, notice: null })
1146  })
1147}
1148
types/index.d.ts 77 lines
1/**
2 * Where the clean stop stands:
3 * - `armed`: watching the credit windows, nothing happens.
4 * - `stopping`: threshold reached, agents warned, no new agent.
5 * - `overage`: past 100 %, the clean stop goes on within the overage budget.
6 * - `stopped`: the clean stop is over, no request leaves anymore.
7 * - `braked`: the overage budget ran out, no request leaves anymore.
8 * - `off`: the mod is off for this session (turned off, or not armed at start).
9 */
10export type GracefulStopPhase = 'armed' | 'stopping' | 'overage' | 'stopped' | 'braked' | 'off'
11
12/** A subagent the clean stop is waiting on, as it was when first seen. */
13export type GracefulStopAgent = {
14  id: string
15  description: string
16  type: string
17  /** Its last known status (AgentStatus), refreshed as the stop goes on. */
18  status: string
19  /** Its final answer, the status report, once its run ended. */
20  report: string | null
21}
22
23export type GracefulStopStatus = {
24  phase: GracefulStopPhase
25  /** What triggered the stop, e.g. "five_hour 90 %" or "manuel". */
26  trigger: string | null
27  /** When the credit window that triggered resets, ISO 8601. */
28  resetsAt: string | null
29  /** The session's cost when the windows passed 100 %, in USD. */
30  baselineUsd: number | null
31  /** Estimated overage spent since then, in USD. */
32  spentUsd: number
33  /** Subagents warned, by id: tool calls each made since its warning. */
34  warned: Record<string, number>
35  /** The resume memo's absolute path, once the stop wrote one. */
36  memoPath: string | null
37  /** Every subagent the stop covers, kept even once it finished. */
38  agents: GracefulStopAgent[]
39  /** Main turns ended with every agent done but the memo still provisional. */
40  idleTurns: number
41  /**
42   * A hash of the provisional memo the mod last wrote: any other content is
43   * the orchestrator's. Absent from a status kept by an older version.
44   */
45  fallbackHash?: number | null
46}
47
48/** A credit window as the engine reports it (SessionRateLimit). */
49export type GracefulStopWindow = {
50  /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
51  kind: string
52  percentUsed: number
53  /** When the window resets, ISO 8601. */
54  resetsAt?: string
55}
56
57/** The session's last credit reading, as the band draws it. */
58export type GracefulStopCredit = {
59  windows: GracefulStopWindow[]
60  /** When it was read, in ms since the epoch. */
61  at: number
62}
63
64declare module 'claude-code' {
65  interface PluginState {
66    'graceful-stop': {
67      status: GracefulStopStatus
68      /** The last reading of this session; null before its first request. */
69      credit: GracefulStopCredit | null
70      /** The current minute, ticked so the band's reset countdowns move. */
71      minute: number
72      /** Whether this session's start was seen: a reload must not disarm it again. */
73      started: boolean
74    }
75  }
76}
77