Saves usage in long Claude Code chats: checkpoints, a fresh chat instead of re-reading a big or cold one, new-topic notices, and Sonnet to Opus escalation for…

PitStop is a mod for the Claude Code desktop app that cuts how much of your usage goes on re-reading long chats.
A long chat is expensive in two ways. Every message re-reads the whole thing, and if you come back after more than an hour the cached copy has expired, so the whole chat is written again. This mod keeps a running checkpoint of each chat and, when re-reading would be wasteful, opens a fresh chat from the checkpoint instead. The new chat opens with the brief already typed in, and you press Enter to send it.
Since Claude desktop app 2.1.304 (October 2026) a mod can no longer open a chat and send to it by itself. Opening a chat goes through the app's start_session tool, and the app now accepts that call only when a model request asked for it or when you typed the words yourself. A mod's call is neither. This is a deliberate safety rule, not a bug.
So PitStop now opens the new chat through the app's own link, claude://code/new?q=…. The app opens a new Code chat with the brief in the prompt box, unsent. Your Enter makes it your own message, which is all the app asks for. The brief is also copied to your clipboard and saved to data/<session>.brief.md as a backup. If it is longer than the link allows (about 14k characters), it goes by clipboard only: press ⌘N, paste, send.
| Try | Result |
|---|---|
Call start_session from the mod | Refused: "auto mode classifier gave no verdict" |
Call it through the mod's own $.mcp.call | Refused, same reason |
Allow rule for start_session in settings.json | Not enough: the rule is not consulted for a call nobody asked for |
Hold the message, show Answer in a fresh chat, pass the press as consent | Refused by the classifier in Auto mode |
| The same press outside Auto mode | The app asked to allow it, then the tool refused: "Start sessions only on the user's own words to you here" |
| Ask a model turn to make the call | Works, but that turn has to re-read the old chat first, so it saves nothing |
Propose the session as a card (initiation: "own_initiative") | Allowed, but three clicks per move |
| Brief to the clipboard, you click New and paste | Worked (the interim version) |
claude://code/new?q=… link | Works: one press. Adding &folder= made the app ask "Trust this workspace?" every time, so the link leaves it out and the new chat opens in the last-used folder |
If you find a way to open a chat with fewer presses, please say so in Discussions (or an issue).
Commands: /pitstop (status), /pitstop-checkpoint (write one now), /pitstop-copy [message] (put the same brief a move builds on your clipboard, any time), /pitstop-test away N | size N | full N | early N | off (try other thresholds for one chat).
At the prompt of a Claude Code terminal session:
/plugin install pitstop --marketplace the-duke-mako/claude-code-pitstop
or from a shell:
claude plugin install pitstop --marketplace the-duke-mako/claude-code-pitstop
To run it from a clone while you tinker, list the folder in CLAUDE_CODE_PLUGIN_DIRS (in the env block of ~/.claude/settings.json) or start a session with claude --plugin-dir <folder>.
macOS only. The move uses /usr/bin/open for the app link and printenv to find the old chat's id.
Earlier versions asked you to add an allow rule for mcp__ccd_session__start_session. That rule is no longer needed: the mod doesn't call that tool any more, so you can remove it.
Optional: when the new chat moves the old chat into the sidebar group, the app asks you to approve it once per move. To skip that card, add this allow rule to ~/.claude/settings.json yourself (the mod won't):
{ "permissions": { "allow": ["mcp__ccd_sidebar__move_sessions"] } }
The trade-off: Claude can then move any chat between sidebar groups without asking. That is low risk, but it's your call.
This is a personal tool shared as-is, not a polished product.
claude-code mod API, which changes between releases. The self-check warns when something stops working after an update.claude-opus-5-5) and thresholds (60 min, 100k, 130k, 200k) are constants at the top of hooks/register.tsx.%; until then it shows tokens.data/ folder. They contain your chat content, so don't commit or share that folder (it is git-ignored here).The control strip is in the spirit of Effort Cycle (MIT) by Anerco.
MIT. See LICENSE.
hooks/register.tsx 1041 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, PromptOrigin, Register, TurnStepChunk, TurnStepInput, TurnStepResult } from 'claude-code'
3
4import type { Calib, Health, PreHandover, Measure, Move, Notice, Override, Part, Saving, SessMemo, Sort, Tune } from '../types'
5
6// PitStop, Phase 1.
7// - Notices on the strip: the chat past 200k, or Haiku sees real work on another project.
8// "Start fresh chat" has the chat write a handover (a fork, cache warm), then opens a session.
9// - Rolling checkpoint: when a task wraps up in a chat ≥200k, a fork writes a full handover;
10// after every reply Haiku adds one line to the chat's notes.
11// - After >1h away from a chat ≥100k, the next message is not sent to the model: the mod answers
12// that turn itself, calling start_session with checkpoint + notes + the message, so the big
13// chat is never re-read.
14// - Past 200k, once a task wraps up and its checkpoint is saved, the next message moves the same
15// way (measured on one user's past chats: ~20% of usage was re-reading big chats).
16// - Savings counter: re-reading skipped, net of the mod's own calls, turned into % of a 5-hour
17// window by calibrating against the real limit % that session.measure reports.
18// - Self-check: each part reports ok/fail; a failure (worded for an app update when the version
19// changed) goes on the strip.
20
21const AWAY_MIN = 60
22const MOVE_MIN_K = 100
23const FULL_K = 200
24// Checkpoints are written only from here up (= FULL_K); below it a move after an hour away has the short notes only.
25const CHECKPOINT_MIN_K = 200
26// Shared prefix a fresh chat reads from cache, and what it writes, measured in testing.
27const FRESH_READ = 44_000
28const FRESH_WRITE = 47_000
29// Calibrated units needed before the counter shows % of a 5-hour window instead of tokens.
30const CALIB_MIN_UNITS = 3_000_000
31
32const START = 'mcp__ccd_session__start_session'
33const START_SERVER = 'ccd_session'
34const START_TOOL = 'start_session'
35const OPEN_TEXT = 'Open the fresh chat'
36// Handovers written ahead of the button: one when a notice appears, one after this long without a message
37// (never two in a row), then none: the button writes it and the user waits. Inside the 1-hour cache.
38const PRE_MAX = 2
39const GAP_MS = 10 * 60_000
40const CACHE_MS = 55 * 60_000
41const COPIED = 'copied'
42const OPENED = 'opened'
43const SAVED_ONLY = 'saved'
44// The app cuts a claude://code/new prompt at 14,336 characters; a longer brief goes by clipboard only.
45const LINK_MAX = 14_000
46const briefPath = (root: string, sid: string) => `${root}/data/${sid}.brief.md`
47const WRAP_UP = 'mcp__ccd_turn__wrap_up'
48// Sonnet → Opus: switched in place while the chat is under this size (a switch re-reads the chat),
49// above it only a "continue on Opus in a fresh chat" notice.
50const OPUS = 'claude-opus-5-5'
51const EARLY_MAX_K = 130
52const OPUS_JOBS: Record<string, 'medium' | 'high'> = { DESIGN: 'high', BUILD: 'high', WRITE: 'high', PLAN: 'medium' }
53const JOB_WORDS: Record<string, string> = { DESIGN: 'design work', BUILD: 'a big build', WRITE: 'client writing', PLAN: 'planning' }
54// A fresh chat can't be opened on a bigger model than its parent's, so its brief carries this line
55// and the mod in the new chat applies it.
56const MARK = /\[pitstop: run this chat on (claude-opus-[\w-]+) · (medium|high)\]/
57
58const measure = atom({ plugin: 'pitstop', key: 'measure' } as const, {} as Measure)
59const notices = atom({ plugin: 'pitstop', key: 'notices' } as const, [] as Notice[])
60const dismissed = atom({ plugin: 'pitstop', key: 'dismissed' } as const, [] as string[])
61const busy = atom({ plugin: 'pitstop', key: 'busy' } as const, null as string | null)
62const move = atom({ plugin: 'pitstop', key: 'move' } as const, null as Move | null)
63const skipMove = atom({ plugin: 'pitstop', key: 'skipMove' } as const, false)
64const lastAnswer = atom({ plugin: 'pitstop', key: 'lastAnswer' } as const, '')
65const lastPrompt = atom({ plugin: 'pitstop', key: 'lastPrompt' } as const, '')
66const taskDone = atom({ plugin: 'pitstop', key: 'taskDone' } as const, false)
67const checkpointAt = atom({ plugin: 'pitstop', key: 'checkpointAt' } as const, null as number | null)
68const tune = atom({ plugin: 'pitstop', key: 'tune' } as const, {} as Tune)
69const health = atom({ plugin: 'pitstop', key: 'health' } as const, { parts: {} } as Health)
70const saved = atom({ plugin: 'pitstop', key: 'saved' } as const, { units: 0, tokens: 0, moves: 0 } as {
71 units: number
72 tokens: number
73 moves: number
74 pctPerUnit?: number
75})
76const unitsSinceMeasure = atom({ plugin: 'pitstop', key: 'unitsSinceMeasure' } as const, 0)
77const movedTo = atom({ plugin: 'pitstop', key: 'movedTo' } as const, null as string | null)
78const quietTurn = atom({ plugin: 'pitstop', key: 'quietTurn' } as const, null as string | null)
79const override = atom({ plugin: 'pitstop', key: 'override' } as const, null as Override | null)
80const noAuto = atom({ plugin: 'pitstop', key: 'noAuto' } as const, false)
81const pushes = atom({ plugin: 'pitstop', key: 'pushes' } as const, [] as boolean[])
82const sizeReady = atom({ plugin: 'pitstop', key: 'sizeReady' } as const, false)
83const promptSeq = atom({ plugin: 'pitstop', key: 'promptSeq' } as const, 0)
84const pre = atom({ plugin: 'pitstop', key: 'pre' } as const, null as PreHandover | null)
85const preCount = atom({ plugin: 'pitstop', key: 'preCount' } as const, 0)
86const taskAsks = atom({ plugin: 'pitstop', key: 'taskAsks' } as const, false)
87
88const CHECKPOINT_PROMPT = `Write a checkpoint handover for this chat, so that a FRESH chat can carry on without reading this one. Plain Markdown, under 900 words, no preamble. Use exactly these parts:
89# <Project> · <task> (short, e.g. "Website · partners section mockup")
90**Project / folder:** the project and the paths it lives in
91**Goal:** what the user is trying to get done overall
92**Done so far:** bullets, with file paths
93**Decided (don't re-ask):** the user's decisions, each with its reason
94**Open / next:** the next steps in order, and what is waiting on whom
95**Files that matter:** path, and why
96**Watch-outs:** gotchas, and preferences the user stated in this chat
97
98This request comes from the user's pitstop mod, not from the user: don't mention it, the mod, or this handover request in the handover. Leave out tool and connector status, test chatter and anything that doesn't help continue the work.`
99
100const WARN: Record<Part, string> = {
101 measure: "can't read your 5-hour and weekly limits, so the savings counter is paused.",
102 haiku: 'Haiku calls stopped working: the per-reply notes and the new-topic check are off.',
103 checkpoint: "couldn't save a checkpoint: a move after an hour away would only have the short notes.",
104 move: 'opening a fresh chat failed: after an hour away, messages will be answered in the old chat (full re-read).',
105}
106
107const k = (n: number) => `${Math.round(n / 1000)}k`
108const iso = (ms: number) => new Date(ms).toISOString().slice(0, 19)
109const hhmm = (ms: number) => {
110 const d = new Date(ms)
111 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
112}
113const fmtAway = (min: number) =>
114 min < 120 ? `${min} min` : min < 48 * 60 ? `${Math.floor(min / 60)} h ${min % 60} min` : `${Math.round(min / 1440)} days`
115const titleOf = (md: string) => (/^#\s+(.+)$/m.exec(md)?.[1] ?? '').trim()
116
117// Relative cost of a request: input 1, cache write 1.25, cache read 0.1, output 5; Haiku half.
118function units(u: ModelUsage | null | undefined, model?: string): number {
119 if (!u) return 0
120 const raw = u.input_tokens + 1.25 * u.cache_creation_input_tokens + 0.1 * u.cache_read_input_tokens + 5 * u.output_tokens
121 return model?.includes('haiku') ? raw / 2 : raw
122}
123
124// Re-reading a cold chat writes it all to cache again; a fresh chat writes ~47k and reads the shared ~44k.
125function avoidedUnits(ctx: number, promptChars: number): number {
126 const rebuild = Math.max(0, ctx - FRESH_READ) * 1.25 + FRESH_READ * 0.1
127 const fresh = (FRESH_WRITE + promptChars / 4) * 1.25 + FRESH_READ * 0.1
128 return Math.max(0, rebuild - fresh)
129}
130
131function isPerson(o: PromptOrigin | undefined): boolean {
132 if (!o) return true
133 if (o.kind === 'plugin') return 'asUser' in o && o.asUser === true
134 // In the desktop app the user's typing is 'composer'; a fresh chat's end-of-turn note back to
135 // this chat arrives as 'sdk', so it must not count as the user.
136 return o.kind === 'composer' || o.kind === 'bridge'
137}
138
139const paths = (root: string, sid: string) => ({
140 checkpoint: `${root}/data/${sid}.checkpoint.md`,
141 notes: `${root}/data/${sid}.notes.md`,
142 handover: `${root}/data/${sid}.handover.md`,
143})
144
145async function readText($: EngineInterface, path: string): Promise<string> {
146 try {
147 return await $.fs.read(path)
148 } catch {
149 return ''
150 }
151}
152
153// One line per thing the mod did, so each feature can be checked from evidence.
154async function trace($: EngineInterface, root: string, sid: string, line: string) {
155 const path = `${root}/data/${sid}.trace.txt`
156 const now = await $.clock.now()
157 await $.fs.write(path, `${await readText($, path)}${iso(now)} ${line}\n`)
158}
159
160async function addNotice($: EngineInterface, n: Notice) {
161 await update($, notices, ns => [...ns.filter(x => x.id !== n.id && !(n.kind === 'topic' && x.kind === 'topic')), n])
162}
163
164async function dismiss($: EngineInterface, n: Notice) {
165 await update($, notices, ns => ns.filter(x => x.id !== n.id))
166 if (n.kind === 'full' || n.kind === 'escalate') await update($, dismissed, d => [...d, n.kind])
167}
168
169async function report($: EngineInterface, part: Part, ok: boolean) {
170 const before = await read($, health)
171 if (before.parts[part] === (ok ? 'ok' : 'fail')) return
172 const h = await update($, health, cur => ({ ...cur, parts: { ...cur.parts, [part]: ok ? 'ok' : 'fail' } }))
173 if (!ok) {
174 const lead = h.verifying ? `⚠ Since the app update to ${h.version}, PitStop` : '⚠ PitStop'
175 await addNotice($, { id: `warn-${part}`, kind: 'warn', text: `${lead} ${WARN[part]}` })
176 } else {
177 await update($, notices, ns => ns.filter(x => x.id !== `warn-${part}`))
178 if (h.verifying && h.parts.measure === 'ok' && h.parts.haiku === 'ok') {
179 const done = await update($, health, cur => ({ ...cur, verifying: false }))
180 await $.store.set('health', { version: done.version, verifying: false, parts: {} })
181 $.ui.toast(`PitStop checked itself after the app update to ${done.version}: working.`)
182 await $.fs.write(`${$.plugin.root}/data/self-check.txt`, `${iso(await $.clock.now())} app ${done.version}: checked after the update, working (limits + Haiku ok)\n`)
183 }
184 }
185}
186
187async function refreshSaved($: EngineInterface) {
188 const now = await $.clock.now()
189 const all = (((await $.store.get('savings')) as Saving[] | undefined) ?? []).filter(s => now - s.at < 7 * 86_400_000)
190 await $.store.set('savings', all)
191 const c = (await $.store.get('calib')) as Calib | undefined
192 await update($, saved, () => ({
193 units: all.reduce((a, s) => a + s.units, 0),
194 tokens: all.reduce((a, s) => a + s.tokens, 0),
195 moves: all.filter(s => s.tokens > 0).length,
196 pctPerUnit: c && c.units >= CALIB_MIN_UNITS && c.pct > 0 ? c.pct / c.units : undefined,
197 }))
198}
199
200async function addSaving($: EngineInterface, units: number, tokens: number) {
201 if (!units && !tokens) return
202 const now = await $.clock.now()
203 const all = ((await $.store.get('savings')) as Saving[] | undefined) ?? []
204 await $.store.set('savings', [...all, { at: now, units: Math.round(units), tokens }])
205 await refreshSaved($)
206}
207
208// The mod's own model calls count against what it saves, and toward calibration.
209async function overhead($: EngineInterface, u: ModelUsage | undefined, model?: string) {
210 const n = units(u, model)
211 if (!n) return
212 await update($, unitsSinceMeasure, x => x + n)
213 await addSaving($, -n, 0)
214}
215
216function savedText(s: { units: number; tokens: number; moves: number; pctPerUnit?: number }): string {
217 if (s.moves === 0) return 'no re-reads skipped yet this week'
218 if (s.units < 5000) return `${s.moves} re-read${s.moves === 1 ? '' : 's'} skipped this week`
219 if (s.pctPerUnit !== undefined) {
220 const pct = Math.max(0, s.units * s.pctPerUnit)
221 return `saved ≈${pct < 10 ? pct.toFixed(1) : Math.round(pct)}% of a 5-hour window this week`
222 }
223 return `saved ≈${k(Math.max(0, s.units))} tokens of re-reading this week`
224}
225
226async function writeCheckpoint($: EngineInterface, root: string, sid: string, why: string): Promise<boolean> {
227 const r = await $.model.fork({ prompt: CHECKPOINT_PROMPT })
228 if (!r.isAnswered) {
229 if (r.reason !== 'nothing-to-fork' && r.reason !== 'aborted') await report($, 'checkpoint', false)
230 return false
231 }
232 await overhead($, r.usage)
233 const now = await $.clock.now()
234 await $.fs.write(paths(root, sid).checkpoint, `<!-- checkpoint ${iso(now)} (${why}) -->\n${r.text.trim()}\n`)
235 await update($, checkpointAt, () => now)
236 await report($, 'checkpoint', true)
237 await trace($, root, sid, `checkpoint written (${why}), fork read ${r.usage.cache_read_input_tokens} from cache, wrote ${r.usage.cache_creation_input_tokens}, out ${r.usage.output_tokens}`)
238 return true
239}
240
241async function addNote($: EngineInterface, root: string, sid: string, prompt: string, answer: string) {
242 const r = await $.model.complete({
243 model: 'haiku',
244 maxTokens: 80,
245 prompt: `Summarise this exchange from a work chat in ONE line of at most 25 words, past tense: what was done, and what is next if it says. No preamble.\n\nUSER: ${prompt.slice(0, 2000)}\n\nASSISTANT: ${answer.slice(-2500)}`,
246 })
247 if (!r.isAnswered) {
248 if (r.reason !== 'aborted') await report($, 'haiku', false)
249 return
250 }
251 await report($, 'haiku', true)
252 await overhead($, r.usage, 'haiku')
253 const path = paths(root, sid).notes
254 const now = await $.clock.now()
255 const line = r.text.trim().split('\n')[0] ?? ''
256 await $.fs.write(path, `${await readText($, path)}- ${iso(now)} · ${line}\n`)
257 await trace($, root, sid, `note: ${line}`)
258}
259
260const SORT_PROMPT = `You sort one message in a Claude Code work chat. Reply with exactly three lines:
261TOPIC: SAME | SIDE | NEW: <Project> · <task>
262JOB: <TINY|EDIT|DESIGN|BUILD|PLAN|WRITE> <SURE|UNSURE>
263PUSHBACK: YES | NO
264
265TOPIC: SAME continues the current work (follow-ups, fixes, approvals, "go", questions about it); SIDE is a quick side question or a tiny one-off; NEW is real work on a DIFFERENT project or an unrelated task, with a short name such as "Website · footer spacing". With no earlier work, answer SAME.
266JOB, for what the message asks for:
267TINY: rename or move, a typo, a log entry, a quick lookup, a status check or a general-knowledge question (not one that needs digging through code)
268EDIT: normal edits (CSS, copy tweaks, a small bug), proofreading or checking copy, going through a document
269DESIGN: visual design, creative work, layouts, images, brand
270BUILD: a big build, a new page, app or feature, hard debugging, or a security, code or performance audit; a short question about whether an app or site is safe, broken or correct ("is X weak to SQL injection", "why does X crash") is BUILD, because answering it means reading the code
271PLAN: research, strategy, decisions, "should we"
272WRITE: writing or reworking client-facing copy from scratch (pitch, proposal, page copy, an important email)
273SURE means clearly that job; UNSURE means it could be another.
274PUSHBACK: YES only if the message says the last answer was wrong, didn't work, or missed what was asked.`
275
276async function sortMessage($: EngineInterface, text: string, answer: string, notes: string): Promise<Sort | null> {
277 const r = await $.model.complete({
278 model: 'haiku',
279 maxTokens: 60,
280 timeoutMs: 6000,
281 prompt: `${SORT_PROMPT}
282
283Recent work in this chat:
284${notes || '(none yet)'}
285
286Last reply (end of it):
287${answer ? answer.slice(-1500) : '(none yet)'}
288
289Message:
290${text.slice(0, 1500)}`,
291 })
292 if (!r.isAnswered) {
293 if (r.reason !== 'aborted') await report($, 'haiku', false)
294 return null
295 }
296 await report($, 'haiku', true)
297 await overhead($, r.usage, 'haiku')
298 const t = r.text
299 const job = /JOB:\s*(TINY|EDIT|DESIGN|BUILD|PLAN|WRITE)\s*(SURE|UNSURE)?/i.exec(t)
300 return {
301 topic: /TOPIC:\s*NEW:\s*(.+)$/im.exec(t)?.[1]?.trim().slice(0, 60),
302 job: job?.[1]?.toUpperCase(),
303 sure: (job?.[2] ?? '').toUpperCase() === 'SURE',
304 pushback: /PUSHBACK:\s*YES/i.test(t),
305 raw: t.trim().replace(/\n/g, ' | ').slice(0, 160),
306 }
307}
308
309// Turns the sort into notices and switches. `ctx` is the chat's size, `onSonnet` whether the
310// app runs this chat on Sonnet with no switch of ours in place.
311async function actOnSort($: EngineInterface, root: string, sid: string, srt: Sort, text: string, hadAnswer: boolean) {
312 await trace($, root, sid, `sort: ${srt.raw}`)
313 const ps = await update($, pushes, p => [...p.slice(-5), srt.pushback])
314 const misses = ps.filter(Boolean).length
315 if (srt.topic && hadAnswer) {
316 const topicNotice: Notice = {
317 id: `topic-${await $.clock.now()}`,
318 kind: 'topic',
319 text: `This looks like new work: ${srt.topic}. A fresh chat keeps this one focused and costs less per message.`,
320 newWork: text,
321 }
322 await addNotice($, topicNotice)
323 startPre($, root, sid, topicNotice)
324 }
325 const m = await read($, measure)
326 const ov = await read($, override)
327 const onSonnet = !ov && (m.appModel === undefined || m.appModel.includes('sonnet'))
328 if (!onSonnet) return
329 const effort = srt.job !== undefined && srt.sure ? OPUS_JOBS[srt.job] : undefined
330 const why = effort && srt.job ? JOB_WORDS[srt.job] ?? 'bigger work' : misses >= 2 ? 'two misses in a row' : undefined
331 if (!why) return
332 const ctx = m.ctxTokens ?? 0
333 if (ctx < ((await read($, tune)).earlyK ?? EARLY_MAX_K) * 1000) {
334 // Undo turned the in-place switch off for this chat; the later notice still applies.
335 if (await read($, noAuto)) return
336 await update($, override, () => ({ model: OPUS, effort: effort ?? 'high', why, announced: false }))
337 await trace($, root, sid, `auto-switch armed: Opus · ${effort ?? 'high'} for ${why} (chat ${k(ctx)})`)
338 return
339 }
340 if ((await read($, dismissed)).includes('escalate') || (await read($, notices)).some(n => n.kind === 'escalate')) return
341 await trace($, root, sid, `notice: continue on Opus (${why}, chat ${k(ctx)})`)
342 const escNotice: Notice = {
343 id: 'escalate',
344 kind: 'escalate',
345 toOpus: true,
346 text: `This has turned into ${why} on Sonnet. Switching here would re-read all ${k(ctx)}; a fresh chat on Opus starts small.`,
347 }
348 await addNotice($, escNotice)
349 startPre($, root, sid, escNotice)
350}
351
352// Runs the sorter over a list of past messages (one per line) and writes what it picked.
353async function sortCheck($: EngineInterface, root: string) {
354 const lines = (await readText($, `${root}/data/sort-sample.txt`)).split('\n').filter(l => l.trim())
355 const out: string[] = []
356 await $.fs.write(`${root}/data/sort-check.txt`, `started, ${lines.length} messages\n`)
357 for (const l of lines) {
358 const msg = l.replace(/^\d+\.\s*/, '')
359 const srt = await sortMessage($, msg, '', '')
360 out.push(`${l.slice(0, 4).trim()} ${srt ? `${srt.job ?? '?'} ${srt.sure ? 'SURE' : 'UNSURE'}${srt.pushback ? ' PUSHBACK' : ''}` : 'no answer'} | ${msg.slice(0, 110)}`)
361 await $.fs.write(`${root}/data/sort-check.txt`, `${out.join('\n')}\n`)
362 }
363 await $.fs.write(`${root}/data/sort-check.txt`, `${out.join('\n')}\n`)
364 $.ui.toast(`Sorter check done: ${out.length} messages sorted.`)
365}
366
367// After a reply: the Haiku note, a checkpoint when a task wrapped up in a big chat (or the chat
368// just got big with none yet), and the "past 200k" notice.
369async function afterReply($: EngineInterface, root: string, sid: string, prompt: string, answer: string, wasTaskDone: boolean, replies: number, seq: number | null) {
370 const m = await read($, measure)
371 const t = await read($, tune)
372 const ctx = m.ctxTokens ?? 0
373 const fullK = t.fullK ?? FULL_K
374 const big = ctx >= fullK * 1000 && !(await read($, dismissed)).includes('full')
375 if (big && !(await read($, notices)).some(n => n.kind === 'full')) {
376 await trace($, root, sid, `notice: past ${fullK}k (${k(ctx)})`)
377 await addNotice($, {
378 id: 'full',
379 kind: 'full',
380 text: `This chat is past ${fullK}k (now ${k(ctx)}). Past this size each message re-reads a lot, costs more and tends to drift. When a task here is finished, your next message opens a fresh chat from its checkpoint (Dismiss keeps this chat).`,
381 })
382 }
383 if (replies >= 3 && !m.measureSeen) await report($, 'measure', false)
384 await addNote($, root, sid, prompt, answer)
385 if (ctx >= (t.fullK ?? CHECKPOINT_MIN_K) * 1000 && (wasTaskDone || (await read($, checkpointAt)) === null)) {
386 const ok = await writeCheckpoint($, root, sid, wasTaskDone ? 'task finished' : 'first in a big chat')
387 // Only if the user hasn't typed again while the checkpoint was being written: then it is current.
388 if (ok && wasTaskDone && big && seq !== null && (await read($, promptSeq)) === seq) {
389 await update($, sizeReady, () => true)
390 await trace($, root, sid, `size move ready: task finished at ${k(ctx)}, the next message opens a fresh chat`)
391 }
392 }
393}
394
395// A "Project · task" name when there is no checkpoint title to take it from.
396async function nameChat($: EngineInterface, notes: string, text: string): Promise<string> {
397 const r = await $.model.complete({
398 model: 'haiku',
399 maxTokens: 20,
400 prompt: `Name this work chat as "Project · task", at most 6 words (e.g. "Website · footer spacing"). Reply with the name only.\n\nNotes so far:\n${notes.slice(-1500)}\n\nNew message:\n${text.slice(0, 600)}`,
401 })
402 if (!r.isAnswered) return ''
403 await overhead($, r.usage, 'haiku')
404 return (r.text.trim().split('\n')[0] ?? '').replace(/^["“]|["”]$/g, '').slice(0, 58)
405}
406
407// Decides whether the message just typed goes to a fresh chat, and builds that chat's brief.
408async function checkAway($: EngineInterface, root: string, sid: string, text: string): Promise<Move | null> {
409 const t = await read($, tune)
410 const m = await read($, measure)
411 const memo = (await $.store.get(`sess:${sid}`)) as SessMemo | undefined
412 const lastAt = m.lastRealAt ?? memo?.lastRealAt
413 const ctx = m.ctxTokens ?? memo?.ctxTokens
414 if (!lastAt || !ctx) return null
415 const awayMin = Math.round(((await $.clock.now()) - lastAt) / 60_000)
416 const away = awayMin >= (t.awayMin ?? AWAY_MIN) && ctx >= (t.moveMinK ?? MOVE_MIN_K) * 1000
417 const size =
418 !away && !text.startsWith('/') && (await read($, sizeReady)) && ctx >= (t.fullK ?? FULL_K) * 1000 && !(await read($, dismissed)).includes('full')
419 if (!away && !size) return null
420 const p = paths(root, sid)
421 const cp = (await readText($, p.checkpoint)).trim()
422 const cpTime = /<!-- checkpoint (\S+)/.exec(cp)?.[1] ?? ''
423 const notes = (await readText($, p.notes))
424 .split('\n')
425 .filter(l => l.startsWith('- ') && l.slice(2, 21) > cpTime)
426 .slice(-40)
427 .join('\n')
428 if (!cp && !notes) return null
429 const gap = fmtAway(awayMin)
430 const title = titleOf(cp) || (await nameChat($, notes, text))
431 const ov = await read($, override)
432 const mark = ov ? `[pitstop: run this chat on ${OPUS} · ${ov.effort}]\n\n` : ''
433 const why = away
434 ? `That chat had grown to ${k(ctx)} tokens and the user came back after ${gap}, so answering there would have meant re-reading all of it.`
435 : `That chat had grown to ${k(ctx)} tokens and had just finished a task, so every further message there would have re-read all of it.`
436 return {
437 phase: 'pending',
438 why: away ? 'away' : 'size',
439 title: (title || 'Continued chat').slice(0, 58),
440 ctxTokens: ctx,
441 awayMin: away ? awayMin : 0,
442 prompt: `${mark}This chat continues work from an earlier chat (session ${sid}). ${why} The pitstop mod opened this fresh chat instead. Don't read the old chat's transcript unless the user asks or something essential is missing.
443
444## Latest checkpoint
445${cp || "(none was written: rely on the notes below, and ask the user if context is missing)"}
446
447## Notes since the checkpoint
448${notes || '(none)'}
449
450## The user's new message (answer this)
451${text}`,
452 background: `Moved here by the PitStop mod (Phase 1) from a ${k(ctx)}-token chat ${away ? `after the user was away ${gap}` : 'after it finished a task past 200k'}, to avoid re-reading it. The checkpoint was written by that chat while it was still cached; the notes are one-line Haiku summaries of each reply since.`,
453 }
454}
455
456// What the chat is asked to write as the handover for a notice (the button and the write-ahead share it).
457function handoverAsk(n: Notice): string {
458 return n.newWork
459 ? `${CHECKPOINT_PROMPT}\n\nThe user is moving to a NEW chat for this request: "${n.newWork.slice(0, 1500)}". Write the handover for THAT request: keep only what from this chat helps with it (files, decisions, preferences). If nothing does, say so in one line.`
460 : `${CHECKPOINT_PROMPT}\n\nThe user is moving to a fresh chat${n.toOpus ? ' on a stronger model, because this work outgrew the current one' : ' because this one is large'}. End with a **Start with:** line saying what the fresh chat should do first.`
461}
462
463// Writes the handover for a notice ahead of the button, while the chat is still warm. At most PRE_MAX
464// per chat; skipped while the button is working.
465async function prewrite($: EngineInterface, root: string, sid: string, n: Notice): Promise<boolean> {
466 if ((await read($, preCount)) >= PRE_MAX || (await read($, busy))) return false
467 const seq = await read($, promptSeq)
468 const r = await $.model.fork({ prompt: handoverAsk(n) })
469 if (!r.isAnswered) return false
470 await overhead($, r.usage)
471 const at = await $.clock.now()
472 const count = await update($, preCount, x => x + 1)
473 await update($, pre, () => ({ id: n.id, text: r.text.trim(), promptSeq: seq, at }))
474 await trace($, root, sid, `handover written ahead for "${n.kind}" notice (${count} of ${PRE_MAX}), fork read ${r.usage.cache_read_input_tokens} from cache, out ${r.usage.output_tokens}`)
475 return true
476}
477
478// Handover #1: as soon as a topic or Opus notice appears (a size notice is covered by the checkpoint).
479function startPre($: EngineInterface, root: string, sid: string, n: Notice) {
480 $.clock.after(0, () => {
481 void read($, preCount).then(c => (c === 0 ? prewrite($, root, sid, n) : undefined))
482 })
483}
484
485// Handover #2: after a pause with no message, only if the user sent something since #1 and the cache is warm.
486async function gapWrite($: EngineInterface, root: string, sid: string, seq: number) {
487 if ((await read($, promptSeq)) !== seq) return
488 const cur = await read($, pre)
489 if (!cur || (await read($, preCount)) !== 1 || cur.promptSeq >= seq) return
490 const lastAt = (await read($, measure)).lastRealAt
491 if (lastAt === undefined || (await $.clock.now()) - lastAt > CACHE_MS) return
492 const n = (await read($, notices)).find(x => x.id === cur.id)
493 if (n) await prewrite($, root, sid, n)
494}
495
496// "Start fresh chat" on a notice: the chat writes its handover while still cached, then the mod
497// sends a prompt as the user, and turn.step answers that turn by opening the session.
498async function startFresh($: EngineInterface, root: string, sid: string, n: Notice) {
499 const m0 = await read($, measure)
500 const ready = await read($, pre)
501 const cpAt = await read($, checkpointAt)
502 let handover = ''
503 let source = ''
504 if (ready && ready.id === n.id && ready.promptSeq === (await read($, promptSeq))) {
505 handover = ready.text
506 source = 'written ahead'
507 } else if (!n.newWork && cpAt !== null && cpAt >= (m0.lastRealAt ?? 0)) {
508 handover = (await readText($, paths(root, sid).checkpoint)).replace(/^<!--[^\n]*-->\n?/, '').trim()
509 source = 'latest checkpoint'
510 }
511 if (!handover) {
512 const r = await $.model.fork({ prompt: handoverAsk(n) })
513 if (!r.isAnswered) {
514 await update($, busy, () => null)
515 await report($, 'checkpoint', false)
516 $.ui.toast("Couldn't write the handover, so no fresh chat was opened.")
517 return
518 }
519 await overhead($, r.usage)
520 handover = r.text.trim()
521 source = 'written now'
522 }
523 await update($, pre, () => null)
524 await $.fs.write(paths(root, sid).handover, `${handover}\n`)
525 const title = (titleOf(handover) || (n.newWork ? n.newWork.slice(0, 40) : 'Continued chat')).slice(0, 58)
526 const m = await read($, measure)
527 const ov = await read($, override)
528 const mark = n.toOpus || ov ? `[pitstop: run this chat on ${OPUS} · ${ov?.effort ?? 'high'}]\n\n` : ''
529 await update($, move, (): Move => ({
530 phase: 'pending',
531 why: 'button',
532 noticeId: n.id,
533 title,
534 ctxTokens: m.ctxTokens ?? 0,
535 awayMin: 0,
536 prompt: `${mark}This chat continues from an earlier chat (session ${sid}) that the user chose to leave${n.toOpus ? ' to carry on with Opus' : n.newWork ? ' for a new piece of work' : ' because it had grown large'}. The pitstop mod had that chat write the handover below while it was still loaded. Don't read the old chat's transcript unless the user asks.${n.newWork ? ' The request for this chat is at the end: use the handover only where it helps with that request.' : ''}
537
538## Handover
539${handover}
540
541${n.newWork ? `## The user's request for this chat\n${n.newWork}` : `## Start\n${source === 'latest checkpoint' ? 'Say in two lines where things stand, then ask what to do first.' : 'Pick up from "Start with" in the handover: say in two lines where things stand, then ask what to do first.'}`}`,
542 background: `Opened by the PitStop mod (Phase 1) when the user pressed "Start fresh chat" on a ${n.kind === 'topic' ? 'new-topic' : 'chat-size'} notice. The handover was written by the old chat itself.`,
543 }))
544 await update($, busy, () => 'press Enter to open the fresh chat')
545 // The app opens chats only on the user's own typed words, so the button fills the prompt box and
546 // their Enter starts the turn the mod answers itself.
547 const filled = await $.prompt.fill({ text: OPEN_TEXT })
548 await trace($, root, sid, `button: handover ${source} (${handover.length} chars), prompt box filled: ${JSON.stringify(filled).slice(0, 120)}`)
549 $.ui.toast(`Handover ready. Press Enter on “${OPEN_TEXT}” to open the fresh chat.`)
550}
551
552// Undo on the "switched to Opus" notice: back to the app's model, and no more auto-switching here.
553async function undoSwitch($: EngineInterface, root: string, sid: string, n: Notice) {
554 await update($, override, () => null)
555 await update($, noAuto, () => true)
556 await update($, notices, ns => ns.filter(x => x.id !== n.id))
557 await trace($, root, sid, 'auto-switch undone: back to the app model, auto off for this chat')
558 $.ui.toast('Back to Sonnet for this chat. The mod won’t switch it again.')
559}
560
561// The button answers at once: the strip says what is happening, and further presses do nothing.
562async function pressFresh($: EngineInterface, root: string, sid: string, n: Notice) {
563 if (await read($, busy)) {
564 $.ui.toast('Already on it: writing the handover.')
565 return
566 }
567 await update($, busy, () => 'writing the handover (about 20 seconds)…')
568 $.ui.toast('Writing the handover for the fresh chat (about 20 seconds)…')
569 $.clock.after(0, () => {
570 void startFresh($, root, sid, n)
571 })
572}
573
574// A mod can't open a chat itself since app 2.1.304 (auto mode's classifier refuses a start_session nobody
575// asked for; outside auto mode the tool itself wants the user's own typed words). Instead the app's own
576// link claude://code/new?q=… opens a new Code chat with the brief already in the prompt box, unsent, and
577// the user presses Enter: that makes it their own message, which is all the app asks. The brief is also
578// copied to the clipboard and saved to a file as a backup.
579async function hostSessionId($: EngineInterface): Promise<string> {
580 try {
581 const r = await $.process.run(['/usr/bin/printenv', 'CLAUDE_CODE_HOST_SESSION_ID'], { timeoutMs: 5_000 })
582 const id = r.stdout.trim()
583 return /^local_[\w-]+$/.test(id) ? id : ''
584 } catch {
585 return ''
586 }
587}
588
589// A mod's own call to the app's sidebar tools is blocked by auto mode (no request asked for it), so the
590// brief asks the new chat to file itself next to the old one: the user sends that brief, so it is their ask.
591// A chat can't be nested under another after the fact; a shared sidebar group is the nearest thing.
592function sidebarAsk(oldId: string, title: string): string {
593 if (!oldId) return ''
594 const project = (title.split(' · ')[0] ?? '').trim() || 'Continued chats'
595 return `\n\n## Sidebar (do this first, then answer)\nFile this chat next to the chat it came from (${oldId}): if that chat is already in a sidebar group (get_session), move this chat ("self") into the same group; otherwise use the sidebar group named "${project}" (list_groups, create it if it is missing) and move both chats into it with move_sessions. Say so in one short line, nothing more.`
596}
597
598async function openLink($: EngineInterface, text: string): Promise<boolean> {
599 if (text.length > LINK_MAX) return false
600 try {
601 // No `folder=`: a link that names a folder counts as "external" and the app asks "Trust this
602 // workspace?" every time. Without it the new chat opens in the last-used folder.
603 const url = `claude://code/new?q=${encodeURIComponent(text)}`
604 const r = await $.process.run(['/usr/bin/open', url], { timeoutMs: 10_000 })
605 return r.exitCode === 0
606 } catch {
607 return false
608 }
609}
610
611async function openFresh($: EngineInterface, root: string, sid: string, m: Move): Promise<{ ok: boolean; detail: string }> {
612 const file = briefPath(root, sid)
613 const oldId = await hostSessionId($)
614 const text = m.prompt + sidebarAsk(oldId, m.title)
615 let res: { ok: boolean; detail: string }
616 try {
617 await $.fs.write(file, `${text}\n`)
618 let copied = false
619 try {
620 copied = (await $.ui.copy({ text })).isCopied
621 } catch {
622 copied = false
623 }
624 const opened = await openLink($, text)
625 res = { ok: true, detail: opened ? OPENED : copied ? COPIED : `${SAVED_ONLY} ${file}` }
626 } catch (err) {
627 res = { ok: false, detail: String(err).slice(0, 400) }
628 }
629 await update($, move, x => x && { ...x, result: res })
630 await report($, 'move', res.ok)
631 await trace($, root, sid, `brief for a fresh chat (${text.length} chars, old chat ${oldId || 'id unknown'}) ${res.ok ? 'ok' : 'FAILED'}: ${res.detail.slice(0, 300)}`)
632 return res
633}
634
635async function* fakeStep($: EngineInterface, e: TurnStepInput, root: string, sid: string): AsyncGenerator<TurnStepChunk, TurnStepResult> {
636 const m = await read($, move)
637 let lead = ''
638 if (m && !m.result) {
639 lead =
640 m.why === 'away'
641 ? `You were away ${fmtAway(m.awayMin)}, so I'm opening a fresh chat instead of re-reading this one…\n\n`
642 : m.why === 'size'
643 ? `This chat is ${k(m.ctxTokens)} and the last task is finished, so I'm opening a fresh chat from its checkpoint…\n\n`
644 : 'Opening a fresh chat from the handover…\n\n'
645 yield { kind: 'text', index: 0, text: lead }
646 m.result = await openFresh($, root, sid, m)
647 }
648 const s = await read($, saved)
649 const res = m?.result
650 let text: string
651 if (!m || !res?.ok) {
652 const why = res ? ` (${res.detail.slice(0, 160)})` : ''
653 text =
654 m?.why === 'button'
655 ? `I couldn't open a fresh chat${why}. The handover is saved; you can carry on here.`
656 : `I couldn't open a fresh chat${why}. Nothing is lost: send your message again and it will be answered in this chat.`
657 } else {
658 const how =
659 res.detail === OPENED
660 ? 'I opened a fresh chat with it already typed in (it is on your clipboard too)'
661 : res.detail === COPIED
662 ? 'I copied it to your clipboard'
663 : `I couldn't reach the clipboard, so it is saved in ${briefPath(root, sid)}`
664 const step =
665 res.detail === OPENED
666 ? 'Press Enter there to send it. It also files both chats together in the sidebar'
667 : res.detail === COPIED
668 ? 'Click New and paste (⌘V), then send'
669 : 'Click New, paste it from that file, then send'
670 const lead2 =
671 m.why === 'away'
672 ? `You were away ${fmtAway(m.awayMin)}. This chat is ${k(m.ctxTokens)}, and its stored copy expires after an hour, so answering here would have meant re-reading all of it.`
673 : m.why === 'size'
674 ? `This chat is ${k(m.ctxTokens)} and the last task was finished, so every further message here would re-read all of it.`
675 : 'The handover for the fresh chat is ready.'
676 text = `${lead2} I built the brief for a fresh chat (“${m.title}”: the latest checkpoint, the notes since, and ${m.why === 'button' ? 'the handover' : 'your message'}); ${how}. ${step}. This chat is left as it was${m.why === 'button' ? '' : '; to answer here instead, type your message again'}.`
677 }
678 yield { kind: 'text', index: 0, text }
679 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
680 return { turnId: e.turnId, index: e.index, answer: lead + text, toolUses: [], stopReason: 'end_turn', usage: null }
681}
682
683export const register: Register = on => {
684 let sid = 'unknown'
685 let root = ''
686 let replies = 0
687 let gapTimer: { cancel: () => void } | undefined
688
689 on('session.start', async ($, e, next) => {
690 sid = await $.session.id()
691 root = $.plugin.root
692 for (const [name, description, argumentHint] of [
693 ['pitstop', 'PitStop: status, thresholds, checkpoint and notes files, savings.', ''],
694 ['pitstop-checkpoint', 'PitStop: write a checkpoint of this chat now.', ''],
695 ['pitstop-test', 'PitStop: test thresholds for this chat only (away minutes, move size k, full size k).', '<away N | size N | full N | off>'],
696 ['pitstop-sort-check', 'PitStop: run the job sorter over data/sort-sample.txt and write data/sort-check.txt.', ''],
697 ] as const) {
698 await $.command.register({ name, description, argumentHint })
699 }
700 const version = (await $.session.version()).version
701 const stored = (await $.store.get('health')) as Health | undefined
702 const verifying = stored === undefined ? false : stored.version !== version || stored.verifying === true
703 await update($, health, () => ({ version, verifying, parts: {} }))
704 await $.store.set('health', { version, verifying, parts: {} })
705 await refreshSaved($)
706 await trace($, root, sid, `loaded, app ${version}${verifying ? ' (checking itself after an update)' : ''}`)
707 return next(e)
708 })
709
710 on('command.run', { command: 'pitstop' }, async $ => {
711 const m = await read($, measure)
712 const t = await read($, tune)
713 const h = await read($, health)
714 const s = await read($, saved)
715 const cp = await read($, checkpointAt)
716 const p = paths(root, sid)
717 const parts = Object.entries(h.parts).map(([n, v]) => `${n} ${v}`).join(', ') || 'nothing checked yet'
718 return {
719 text: [
720 `PitStop (Phase 1) · app ${h.version ?? '?'}${h.verifying ? ' · checking itself after an update' : ''}`,
721 `This chat: ${m.ctxTokens ? k(m.ctxTokens) : 'size not known yet'}${m.fiveHour !== undefined ? ` · 5-hour limit ${m.fiveHour}% used` : ''}${m.weekly !== undefined ? ` · weekly ${m.weekly}%` : ''}`,
722 `Moves to a fresh chat after ${t.awayMin ?? AWAY_MIN} min away, when the chat is ${t.moveMinK ?? MOVE_MIN_K}k or more. Past ${t.fullK ?? FULL_K}k: a notice, and a move at the next message once a task is finished${(await read($, sizeReady)) ? ' (ready now)' : ''}. Sonnet→Opus in place under ${t.earlyK ?? EARLY_MAX_K}k, a fresh-chat notice above.${Object.keys(t).length ? ' (test values for this chat)' : ''}`,
723 `Checkpoint: ${cp ? `${hhmm(cp)}, ${p.checkpoint}` : 'none yet'}`,
724 `Notes: ${p.notes}`,
725 `Savings: ${savedText(s)} (${s.moves} move${s.moves === 1 ? '' : 's'}, net of the mod's own calls)`,
726 `Self-check: ${parts}`,
727 ].join('\n'),
728 }
729 })
730
731 on('command.run', { command: 'pitstop-checkpoint' }, async $ => {
732 $.clock.after(0, () => {
733 void writeCheckpoint($, root, sid, 'asked for')
734 })
735 return { text: 'Writing a checkpoint in the background; the strip shows its time when it is saved.' }
736 })
737
738 // /pitstop-copy [message]: the same brief a move builds, on the clipboard on demand. Works any time,
739 // so a lost clipboard or a chat left by hand costs nothing. The message after the command, if any,
740 // goes in as "the user's new message".
741 on('command.run', { command: 'pitstop-copy' }, async ($, e) => {
742 const p = paths(root, sid)
743 const cp = (await readText($, p.checkpoint)).trim()
744 const cpTime = /<!-- checkpoint (\S+)/.exec(cp)?.[1] ?? ''
745 const notes = (await readText($, p.notes))
746 .split('\n')
747 .filter(l => l.startsWith('- ') && l.slice(2, 21) > cpTime)
748 .slice(-40)
749 .join('\n')
750 if (!cp && !notes) return { text: 'PitStop has no checkpoint or notes for this chat yet, so there is nothing to carry over. /pitstop-checkpoint writes one.' }
751 const m = await read($, measure)
752 const said = e.args.trim()
753 const brief = `This chat continues work from an earlier chat (session ${sid}${m.ctxTokens ? `, ${k(m.ctxTokens)} tokens` : ''}). The user moved to a fresh chat instead of re-reading it. Don't read the old chat's transcript unless the user asks or something essential is missing.
754
755## Latest checkpoint
756${cp || '(none was written: rely on the notes below, and ask the user if context is missing)'}
757
758## Notes since the checkpoint
759${notes || '(none)'}
760
761${said ? `## The user's new message (answer this)\n${said}` : '## Start\nSay in two lines where things stand, then ask what to do first.'}
762`
763 await $.fs.write(briefPath(root, sid), brief)
764 let copied = false
765 try {
766 copied = (await $.ui.copy({ text: brief })).isCopied
767 } catch {
768 copied = false
769 }
770 await trace($, root, sid, `/pitstop-copy: brief of ${brief.length} chars, ${copied ? 'on the clipboard' : 'file only'}`)
771 const cpAt = await read($, checkpointAt)
772 const cpNote = cp ? (cpAt ? `checkpoint from ${hhmm(cpAt)}` : 'latest checkpoint') : 'no checkpoint, notes only'
773 return {
774 text: copied
775 ? `Brief copied (${k(Math.round(brief.length / 4))} tokens, ${cpNote}). Press ⌘N, paste with ⌘V, send.`
776 : `Couldn't reach the clipboard. The brief is saved in ${briefPath(root, sid)}: open it, copy it, press ⌘N and paste.`,
777 }
778 })
779
780 on('command.run', { command: 'pitstop-sort-check' }, async $ => {
781 $.clock.after(0, () => {
782 void sortCheck($, root)
783 })
784 return { text: 'Sorting the sample messages with Haiku in the background; a pop-up says when it is done.' }
785 })
786
787 on('command.run', { command: 'pitstop-test' }, async ($, e) => {
788 const [what, raw] = e.args.trim().split(/\s+/)
789 const n = Number(raw)
790 if (what === 'off' || !what) {
791 await update($, tune, () => ({}))
792 return { text: 'PitStop: back to the real thresholds (60 min away, 100k, 200k, switch to Opus in place under 130k).' }
793 }
794 if (!Number.isFinite(n) || (what !== 'away' && what !== 'size' && what !== 'full' && what !== 'early')) {
795 return { text: 'Use: /pitstop-test away <minutes> | size <k> | full <k> | early <k> | off' }
796 }
797 const t = await update($, tune, cur =>
798 what === 'away' ? { ...cur, awayMin: n } : what === 'size' ? { ...cur, moveMinK: n } : what === 'early' ? { ...cur, earlyK: n } : { ...cur, fullK: n },
799 )
800 if (what === 'full') await update($, dismissed, d => d.filter(x => x !== 'full'))
801 return { text: `PitStop test values for this chat: ${JSON.stringify(t)}` }
802 })
803
804 on('prompt.submit', async ($, e, next) => {
805 const mk = MARK.exec(e.text)
806 if (mk?.[1] && e.turnId === undefined && !(await read($, override))) {
807 const effort = mk[2] === 'medium' ? 'medium' : 'high'
808 await update($, override, () => ({ model: mk[1] ?? OPUS, effort, why: 'carried over from the chat before', announced: false }))
809 await trace($, root, sid, `opening brief asks for ${mk[1]} · ${effort}: applied`)
810 }
811 const gone = await read($, movedTo)
812 if (gone !== null && e.turnId === undefined && !isPerson(e.origin) && e.origin?.kind !== 'plugin') {
813 await update($, quietTurn, () => 'pending')
814 await trace($, root, sid, `wake-up after the move (origin ${e.origin?.kind}): answering it in one line, no re-read`)
815 return next(e)
816 }
817 if (isPerson(e.origin) && e.turnId === undefined && gone !== null) await update($, movedTo, () => null)
818 if (e.turnId !== undefined || !isPerson(e.origin)) return next(e)
819 await update($, promptSeq, x => x + 1)
820 const pending = await read($, move)
821 if (pending?.phase === 'pending' && pending.why === 'button') {
822 if (e.text.trim() === OPEN_TEXT) {
823 await trace($, root, sid, "button: the user's Enter on the filled text, opening the chat")
824 return next(e)
825 }
826 await update($, move, () => null)
827 await update($, busy, () => null)
828 }
829 if (await read($, skipMove)) {
830 await update($, skipMove, () => false)
831 } else {
832 const m = await checkAway($, root, sid, e.text)
833 if (m) {
834 await update($, sizeReady, () => false)
835 await update($, move, () => m)
836 await trace($, root, sid, `move armed: ${m.why === 'size' ? 'task finished past the size limit' : `away ${m.awayMin} min`}, chat ${k(m.ctxTokens)}, origin ${JSON.stringify(e.origin)}`)
837 return next(e)
838 }
839 }
840 // A message that stays here starts new work in this chat, so the last checkpoint is no longer current.
841 if (await read($, sizeReady)) {
842 await update($, sizeReady, () => false)
843 await trace($, root, sid, 'size move skipped: this message stays in this chat')
844 }
845 await update($, lastPrompt, () => e.text)
846 const answer = await read($, lastAnswer)
847 const text = e.text
848 if (!text.startsWith('/') && text.trim().length >= 3) {
849 const notes = (await readText($, paths(root, sid).notes)).trim().split('\n').slice(-6).join('\n')
850 const m = await read($, measure)
851 const early =
852 (m.ctxTokens ?? 0) < ((await read($, tune)).earlyK ?? EARLY_MAX_K) * 1000 &&
853 !(await read($, override)) &&
854 !(await read($, noAuto)) &&
855 (m.appModel === undefined || m.appModel.includes('sonnet'))
856 if (early) {
857 // Waits (≤6 s) so a switch to Opus applies to this very reply.
858 const srt = await sortMessage($, text, answer, notes)
859 if (srt) await actOnSort($, root, sid, srt, text, answer !== '')
860 } else {
861 $.clock.after(0, () => {
862 void sortMessage($, text, answer, notes).then(srt => (srt ? actOnSort($, root, sid, srt, text, answer !== '') : undefined))
863 })
864 }
865 }
866 return next(e)
867 }).catch(($, e, next) => next(e))
868
869 on('tool.call', { tool: WRAP_UP }, async ($, e, next) => {
870 await update($, taskDone, () => true)
871 // A wrap-up that asks the user something leaves the task open: their answer belongs in this chat.
872 const ask = (e as { ask?: unknown }).ask
873 await update($, taskAsks, () => typeof ask === 'string' && ask.trim() !== '')
874 return next(e)
875 }).catch(($, e, next) => next(e))
876
877 on('tool.call', { tool: START }, async ($, e, next) => {
878 const m = await read($, move)
879 if (!m || m.phase === 'pending' || m.result) return next(e)
880 const r = await next(e)
881 const ok = r.deny === undefined && r.isError !== true
882 const detail = r.deny ?? JSON.stringify(r.result ?? '').slice(0, 400)
883 await update($, move, x => x && { ...x, result: { ok, detail } })
884 await report($, 'move', ok)
885 await trace($, root, sid, `start_session ${ok ? 'ok' : 'FAILED'}: ${detail.slice(0, 300)}`)
886 return r
887 }).catch(($, e, next) => next(e))
888
889 on('turn.step', async function* ($, e, next) {
890 if (!e.agentId) {
891 const q = await read($, quietTurn)
892 if (q === 'pending' || q === e.turnId) {
893 if (q === 'pending') await update($, quietTurn, () => e.turnId)
894 const text = `“${(await read($, movedTo)) ?? 'The fresh chat'}” has replied; carry on there. (Typing here again continues this chat.)`
895 yield { kind: 'text', index: 0, text }
896 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
897 return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn', usage: null }
898 }
899 const m = await read($, move)
900 if (m && (m.phase === 'pending' || m.phase === e.turnId)) {
901 if (m.phase === 'pending') await update($, move, x => x && { ...x, phase: e.turnId })
902 await trace($, root, sid, `move: answering step ${e.index} myself (no request sent)`)
903 return yield* fakeStep($, e, root, sid)
904 }
905 }
906 let asked = e
907 if (!e.agentId) {
908 const ov = await read($, override)
909 if (ov && e.model.includes('sonnet')) {
910 asked = { ...e, model: ov.model, effort: ov.effort }
911 if (!ov.announced) {
912 await update($, override, x => x && { ...x, announced: true })
913 const ctx = (await read($, measure)).ctxTokens ?? 0
914 await trace($, root, sid, `auto-switch applied: ${ov.model} · ${ov.effort} (${ov.why})`)
915 await addNotice($, {
916 id: 'switched',
917 kind: 'switched',
918 text: `Switched this chat to Opus · ${ov.effort}: ${ov.why}.${ctx ? ` Cheap now, the chat is ${k(ctx)}.` : ''} (The app's model label still says Sonnet.)`,
919 })
920 }
921 }
922 }
923 const r = yield* next(asked)
924 if (!e.agentId && r.usage) {
925 const ctx = r.usage.input_tokens + r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens
926 const now = await $.clock.now()
927 const used = r.usage.model
928 await update($, measure, cur => ({ ...cur, ctxTokens: ctx, lastRealAt: now, model: used, appModel: e.model, effort: asked.effort === undefined ? undefined : String(asked.effort) }))
929 await $.store.set(`sess:${sid}`, { lastRealAt: now, ctxTokens: ctx } satisfies SessMemo)
930 }
931 return r
932 })
933
934 on('turn.complete', async ($, e, next) => {
935 if (e.agentId) return next(e)
936 if ((await read($, quietTurn)) === e.turnId) {
937 await update($, quietTurn, () => null)
938 return next(e)
939 }
940 const m = await read($, move)
941 if (m && m.phase === e.turnId) {
942 await trace($, root, sid, `move turn ended (${e.reason}), result ${JSON.stringify(m.result ?? null).slice(0, 200)}`)
943 await update($, move, () => null)
944 await update($, busy, () => null)
945 if (m.result?.ok) {
946 // The user sends the brief in the new chat themselves, so no hand-back line and no saving counted.
947 if (m.noticeId) await update($, notices, ns => ns.filter(x => x.id !== m.noticeId))
948 if (m.why === 'size') await update($, notices, ns => ns.filter(x => x.kind !== 'full'))
949 } else {
950 await update($, skipMove, () => true)
951 }
952 return next(e)
953 }
954 await update($, unitsSinceMeasure, x => x + units(e.usage, e.usage?.model))
955 if (e.reason !== 'answer') return next(e)
956 replies += 1
957 gapTimer?.cancel()
958 const gapSeq = await read($, promptSeq)
959 gapTimer = $.clock.after(GAP_MS, () => {
960 void gapWrite($, root, sid, gapSeq)
961 })
962 const prompt = await read($, lastPrompt)
963 const wasTaskDone = await read($, taskDone)
964 await update($, taskDone, () => false)
965 await update($, lastAnswer, () => e.answer)
966 const answer = e.answer
967 const n = replies
968 // null: the wrap-up asked the user something, so no size move after it
969 const seq = (await read($, taskAsks)) ? null : await read($, promptSeq)
970 await update($, taskAsks, () => false)
971 $.clock.after(0, () => {
972 void afterReply($, root, sid, prompt, answer, wasTaskDone, n, seq)
973 })
974 return next(e)
975 })
976
977 on('session.measure', async ($, e, next) => {
978 const five = e.rateLimits.find(r => r.kind === 'five_hour')
979 const week = e.rateLimits.find(r => r.kind === 'seven_day')
980 if (five) {
981 // Calibration: limit % gained vs the cost units spent since the last point. Other chats
982 // running at the same time inflate it, so the counter leans high; it says "≈".
983 const cur = await read($, measure)
984 const since = await read($, unitsSinceMeasure)
985 const base = cur.calibBase
986 let nextBase = base
987 if (base === undefined || five.percentUsed < base) {
988 nextBase = five.percentUsed
989 await update($, unitsSinceMeasure, () => 0)
990 } else if (five.percentUsed > base && since > 0) {
991 const c = ((await $.store.get('calib')) as Calib | undefined) ?? { pct: 0, units: 0 }
992 await $.store.set('calib', { pct: c.pct + (five.percentUsed - base), units: c.units + since } satisfies Calib)
993 nextBase = five.percentUsed
994 await update($, unitsSinceMeasure, () => 0)
995 }
996 await update($, measure, m => ({ ...m, fiveHour: five.percentUsed, weekly: week?.percentUsed ?? m.weekly, measureSeen: true, calibBase: nextBase }))
997 if (!cur.measureSeen) await trace($, root, sid, `limits readable: 5h ${five.percentUsed}%, weekly ${week?.percentUsed ?? '?'}%`)
998 await report($, 'measure', true)
999 }
1000 return next(e)
1001 })
1002
1003 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1004 if (e.props.hasSurvey) return next(e)
1005 const m = await read($, measure)
1006 const ns = await read($, notices)
1007 if (!m.ctxTokens && ns.length === 0) return next(e)
1008 const b = await read($, busy)
1009 const cp = await read($, checkpointAt)
1010 const s = await read($, saved)
1011 const { Box, Button, Text } = $.ui.resolve(e)
1012 const ov = await read($, override)
1013 const model = m.model ? `${m.model.includes('opus') ? 'Opus' : m.model.includes('sonnet') ? 'Sonnet' : m.model.replace('claude-', '')}${m.effort ? ` ${m.effort}` : ''}${ov?.announced ? ' (switched by the mod)' : ''}` : ''
1014 const line = ['PitStop', model, m.ctxTokens ? `chat ${k(m.ctxTokens)}` : '', cp ? `checkpoint ${hhmm(cp)}` : '', savedText(s), b ?? '']
1015 .filter(Boolean)
1016 .join(' · ')
1017 return (
1018 <Box flexDirection="column">
1019 <Text dimColor>{line}</Text>
1020 {ns.map(n => (
1021 <Box key={n.id} flexWrap="wrap" alignItems="center">
1022 <Text color={n.kind === 'warn' ? 'warning' : undefined}>{n.text} </Text>
1023 {n.kind === 'warn' ? null : n.kind === 'switched' ? (
1024 <Button key="undo" label="Undo" onPress={() => undoSwitch($, root, sid, n)} />
1025 ) : b ? (
1026 <Text dimColor>{b}</Text>
1027 ) : (
1028 <Button
1029 key="go"
1030 label={n.kind === 'escalate' ? 'Continue on Opus' : 'Start fresh chat'}
1031 onPress={() => pressFresh($, root, sid, n)}
1032 />
1033 )}
1034 <Button key="x" label="Dismiss" onPress={() => dismiss($, n)} />
1035 </Box>
1036 ))}
1037 </Box>
1038 )
1039 })
1040}
1041types/index.d.ts 98 lines1// What the chat looks like right now: size from the last real request, limits from session.measure.
2export type Measure = {
3 ctxTokens?: number
4 lastRealAt?: number
5 fiveHour?: number
6 weekly?: number
7 measureSeen?: boolean
8 // the model and effort the last real request ran on (after any switch)
9 model?: string
10 effort?: string
11 // the model the app itself names for this chat (before any switch of ours)
12 appModel?: string
13 // 5-hour % at the last calibration point (see calibrate)
14 calibBase?: number
15}
16
17// One line on the strip that stays until dismissed.
18export type Notice = {
19 id: string
20 kind: 'full' | 'topic' | 'warn' | 'switched' | 'escalate'
21 text: string
22 // for a topic notice: the new message, so the handover is about it
23 newWork?: string
24 // the fresh chat it opens runs on Opus
25 toOpus?: boolean
26}
27
28// A turn the mod answers itself to open a fresh chat. `phase` is 'pending' until the
29// turn's first step names its turnId.
30export type Move = {
31 phase: string
32 why: 'away' | 'button' | 'size'
33 title: string
34 prompt: string
35 background: string
36 ctxTokens: number
37 awayMin: number
38 result?: { ok: boolean; detail: string }
39 // the notice whose button started it, removed once the chat opens
40 noticeId?: string
41}
42
43// A handover the mod wrote ahead of the button for one notice. `promptSeq` is how many messages
44// the user had sent when it was written: it is current only while they haven't sent another.
45export type PreHandover = { id: string; text: string; promptSeq: number; at: number }
46
47// Test overrides for this chat only (/pitstop-test).
48// What the sorter (Haiku) made of one message.
49export type Sort = { topic?: string; job?: string; sure: boolean; pushback: boolean; raw: string }
50// A model the mod runs this chat on instead of the app's (only ever Sonnet → Opus).
51export type Override = { model: string; effort: 'medium' | 'high'; why: string; announced: boolean }
52
53export type Tune = { awayMin?: number; moveMinK?: number; fullK?: number; earlyK?: number }
54
55export type Part = 'measure' | 'haiku' | 'checkpoint' | 'move'
56export type Health = { version?: string; verifying?: boolean; parts: Partial<Record<Part, 'ok' | 'fail'>> }
57
58// Kept in $.store across chats.
59export type SessMemo = { lastRealAt?: number; ctxTokens?: number }
60export type Saving = { at: number; units: number; tokens: number }
61export type Calib = { pct: number; units: number }
62
63declare module 'claude-code' {
64 interface PluginState {
65 'pitstop': {
66 measure: Measure
67 notices: Notice[]
68 dismissed: string[]
69 busy: string | null
70 move: Move | null
71 skipMove: boolean
72 lastAnswer: string
73 lastPrompt: string
74 taskDone: boolean
75 checkpointAt: number | null
76 tune: Tune
77 health: Health
78 saved: { units: number; tokens: number; moves: number; pctPerUnit?: number }
79 unitsSinceMeasure: number
80 // this chat's work moved to a fresh chat; until the user types here again, wake-ups get one line
81 movedTo: string | null
82 quietTurn: string | null
83 override: Override | null
84 noAuto: boolean
85 pushes: boolean[]
86 // a task finished in a chat past 200k and its checkpoint is saved: the next message moves
87 sizeReady: boolean
88 // counts the user's messages, so a checkpoint finished after they typed again doesn't arm a move
89 promptSeq: number
90 // the last wrap_up asked the user a question (the task waits on their answer)
91 taskAsks: boolean
92 // the handover written ahead for the open notice, and how many were written in this chat
93 pre: PreHandover | null
94 preCount: number
95 }
96 }
97}
98