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

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.

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.
/gs resume relaunches what was left./gs stop winds everything down now, with the same memo, instead of waiting for a threshold./gs resume finds the last memo. If your credit isn't back yet, it tells you when it will be./gs is short for /graceful-stop.
| Command | |
|---|---|
/gs | Show the card |
/gs stop | Start the clean stop now |
/gs resume | Re-arm and pick up from the memo |
/gs on / off | Arm 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.
In /config, set the ▸ graceful-stop row to true to show the settings (reopen /config if they don't appear).
| Setting | Default |
|---|---|
| Armed by default at session start | true (false: each session starts disarmed, /gs on arms it) |
| Session threshold | 90% |
| Weekly threshold | 95% |
| Overage budget | $2 |
| Grace tool calls | 5 |
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%.
SubagentHandback tool. If that changes, the memo loses them but the rest still works.Nothing leaves your machine: the mod makes no network calls.
GRACEFUL-STOP_<date>.md at the root of your repo, during a stop only. It copies the subagents' reports into it.ctrl+x ctrl+a unfolds it, and /gs shows the card anyway./gs stop works./reload-plugins.Bugs and questions: GitHub issues. Security issues: report them privately from the repo's Security tab.
claude plugin validate .
claude plugin test .
Load with --plugin-dir while editing: each save reloads the mod.
MIT, see LICENSE.
hooks/register.tsx 1148 lines1import { 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}
1148types/index.d.ts 77 lines1/**
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