Freeze a Claude Code session at the next safe boundary and resume it later as if nothing happened (ctrl+x f, /freeze)

<img src="assets/logo.svg" alt="Claude Freeze logo" width="112">
<h1 align="center">Claude Freeze</h1>
Stepping away in the middle of a task (a commute, a meeting, a laptop that has to sleep) leaves poor options today. Esc or Ctrl+C cancels the step in progress, and you then have to tell Claude it was only a pause. Ctrl+Z suspends the whole process wherever it happens to be, so a reply that is still streaming can fail once the network drops. Checkpoint and handoff skills carry on in a fresh session, without the live context.
/freeze keeps the session as it is. Whatever is already running finishes, then the session (subagents included) parks before its next step, and it tells you once nothing is talking to the network anymore, so you can close the lid or switch wifi. Run /freeze again and the turn continues exactly where it stopped, with nothing added to the conversation.
The state shows on one line at a time: the spinner while a turn runs, otherwise the status line (or the band, with the optional shortcut):
✻ Freezing · 1 still running · /freeze to cancel (3m 1s) ← finishing in-flight work
❄ Frozen since 08:12 · safe to disconnect · /freeze to resume ← parked: animation and timer stop
❄ Frozen since … line, so the session no longer looks busy./loop wakeups, background-task notifications and messages you type while frozen wait until you resume; a wakeup that fires again with the same prompt is held only once.freezing… while work is still in flight and safe to disconnect once nothing is talking to the network: from then on you can close the lid, switch wifi, or go offline./mcp.Requires Claude Code with mods (function-hook plugins, on by default since 2.1.287; built and tested on 2.1.289) and a POSIX sh (macOS or Linux).
The repo is its own marketplace:
claude plugin marketplace add francktrouillez/claude-freeze
claude plugin install freeze@claude-freeze
Then start a new session, or run /reload-plugins in a running one. /freeze works right away; the shortcut is optional (below).
Update later with:
claude plugin marketplace update claude-freeze
claude plugin update freeze@claude-freeze
git clone https://github.com/francktrouillez/claude-freeze.git ~/Projects/claude-freeze
Then load it in every session by naming the folder in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-freeze"
}
}
(or once, with claude --plugin-dir ~/Projects/claude-freeze). An interactive session watches the folder: saving a file reloads the mod.
Set in /config, under the freeze plugin (the mod reloads by itself). For a clone loaded with CLAUDE_CODE_PLUGIN_DIRS, the same live in ~/.claude/settings.json under "pluginConfigs": { "freeze": { "options": { … } } }.
| Option | Default | What it does |
|---|---|---|
pauseBackground | on | Pause this session's background tasks while frozen (Linux). |
shortcut | off | Show the band above the prompt with the Ctrl+X F button. |
Ctrl+X F shortcutOff by default. Turning it on adds a one-line band above the prompt that carries the button the shortcut presses. While a turn runs, the spinner already shows the state, so the band is only the button; when idle, the band takes over from the status line:
❄ ctrl+x f freeze ← not frozen
❄ ctrl+x f resume ← frozen, turn running (spinner has the rest)
❄ Frozen since 08:12 · safe to disconnect · /freeze or ctrl+x f to resume ← frozen, idle
Turn it on with /freeze shortcut on: it adds the ctrl+x f binding to ~/.claude/keybindings.json (merged with what is there; it never takes over a key bound to something else, nor touches a file that is not valid JSON) and turns the shortcut option on. /freeze shortcut off turns the option off again.
To do it by hand instead: set the shortcut option (above) and add the binding below. Mods cannot register keyboard shortcuts of their own. The band's button borrows an engine action instead, app:cycleDiffBase (normally only live inside the diff panel), and a chord bound to that action at the prompt presses the button. Add it to ~/.claude/keybindings.json (create the file if it does not exist; merge if it does):
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"$docs": "https://code.claude.com/docs/en/keybindings",
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+x f": "app:cycleDiffBase"
}
}
]
}
Why not Ctrl+F: the prompt already uses it to move the cursor forward, so the key never reaches the band. ctrl+x … is Claude Code's own prefix for extra shortcuts and is free at the prompt.
| Action | How |
|---|---|
| Freeze / resume | /freeze (toggle); with the shortcut on, also Ctrl+X F or a click on the band |
| Turn the shortcut on / off | /freeze shortcut on / /freeze shortcut off |
| Know it is safe to disconnect | wait for safe to disconnect |
| Cancel a freeze that is still settling | /freeze again |
| Abandon the parked turn | Esc: the parked step is dropped, never run later |
/freeze works mid-turn (it is an immediate command), including where the shortcut cannot reach: the transcript view (Ctrl+O), an open dialog, the desktop app, or a session followed from another device.
turn.step (before every model request, subagents included) and tool.call (before every tool starts) check the frozen flag. When it is set, the hook waits before letting the step through, so the request is sent only after you resume, unchanged.$ call does not count. A parked hook therefore waits on $.process.spawn of a tiny sh loop that exits once ~/.claude/freeze/<session-id>.thaw exists. Freezing removes that file and resuming creates it. The child is tied to the turn: Esc kills it at once, and the parked step is then refused, never run later.FROZEN while work runs) and shows a toast.Agent, Task, Workflow) are not counted, since their inner steps are. freezing… turns into safe to disconnect when the count reaches zero.pkill -STOP -s), children included, and records it. On resume it continues exactly those (pkill -CONT -s) before the turn goes on. Anything still recorded is also continued when the mod reloads unfrozen and when the session ends. Each record names the Claude process that paused (pid and start time): if that process is gone (a crash, a kill), the next session that starts continues the tasks and drops the record, so nothing stays stopped forever./loop or routine firing, a task notification) identical to one already held is dropped. Prompts you type are never touched.MCP server "x" lost its connection while frozen, run /mcp to reconnect.Esc while frozen.Found a vulnerability? Please report it privately through the repository's Security tab (Report a vulnerability) rather than in a public issue.
[-] at the right of the band (shortcut option only) is drawn by Claude Code on every band above the prompt (it collapses the band, also ctrl+x ctrl+a). A mod cannot hide it. Collapsing the band also unmounts the button, so the shortcut stops working until you expand it again; /freeze still works. Leave the option off for no band at all./proc). On macOS the tasks keep running while frozen, and a toast says so.pauseBackground off if you want it to keep serving while frozen.… for 12m 3s line) includes the time spent frozen.claude plugin validate . # what the module hooks and calls, and anything the engine would refuse
claude plugin test . # tests/*.test.ts against the engine's test kit
tsc -p . # type-check (once the mod has loaded, which lays .claude-plugin/types/)
Files:
.claude-plugin/plugin.json manifest
.claude-plugin/marketplace.json makes the repo installable as a marketplace
hooks/hooks.json names the hooks module
hooks/register.tsx the mod
types/index.d.ts the mod's state contract ($.state)
tests/freeze.test.ts park, resume, toggle, fail-open, background, MCP, spinner, band and shortcut-setup tests
Contributions are welcome: bug reports, ideas and pull requests.
claude --version) and OS.claude plugin validate . and claude plugin test . pass. Mods run on an early-access API, so mention the Claude Code version you tested with.hooks/register.tsx 557 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FreezeState } from '../types'
5
6// The engine keybinding action the band's Button borrows: binding a chord to
7// it in ~/.claude/keybindings.json ("Chat" context) presses the Button.
8const KEY_ACTION = 'app:cycleDiffBase'
9const KEY_LABEL = 'ctrl+x f'
10
11// Every script gets the session id as $1, never spliced in. Files live in a
12// private folder (never a shared /tmp, where another user could plant a
13// symlink or resume the session).
14const DIR = '"$HOME/.claude/freeze"'
15const FILE = `${DIR}/"$1.thaw"`
16const STOPPED = `${DIR}/"$1.stopped"`
17const FREEZE_SH = `umask 077 && mkdir -p ${DIR} && rm -f ${FILE} && [ ! -e ${FILE} ]`
18const THAW_SH = `umask 077 && mkdir -p ${DIR} && touch ${FILE}`
19const WAIT_SH = `until [ -e ${FILE} ]; do sleep 1; done`
20
21// Background tasks are the processes whose stdout is one of this session's
22// task output files (…/<session-id>/tasks/<id>.output). Claude Code starts each
23// in a session of its own (setsid), and every child inherits it, even one in a
24// process group of its own or logging elsewhere: the session is what gets
25// paused. MCP servers share Claude's session, which is never touched, and a
26// session is only paused when its leader is itself a task process. Linux only
27// (/proc); elsewhere nothing is paused. Run once the freeze has settled, when
28// no foreground command is left.
29const PAUSE_SH = `[ -d /proc/self/fd ] || { echo unsupported; exit 0; }
30umask 077 && mkdir -p ${DIR}
31echo "# owner $PPID $(ps -o lstart= -p $PPID | tr -s ' ' '_')" > ${STOPPED}
32own=$(ps -o sid= -p $$ | tr -d ' ')
33pids=$(for fd in /proc/[0-9]*/fd/1; do
34 case "$(readlink "$fd" 2>/dev/null)" in
35 */"$1"/tasks/*) p=\${fd#/proc/}; echo "\${p%%/*}" ;;
36 esac
37done)
38leaders=" $(echo $pids) "
39for pid in $pids; do ps -o sid= -p "$pid"; done | tr -d ' ' | sort -u | while read -r sid; do
40 case "$sid" in ''|*[!0-9]*|0|1) continue ;; esac
41 [ "$sid" = "$own" ] && continue
42 case "$leaders" in *" $sid "*) ;; *) continue ;; esac
43 pkill -STOP -s "$sid" && echo "$sid" >> ${STOPPED}
44done
45grep -c '^[0-9]' ${STOPPED} || true`
46const RESUME_SH = `[ -f ${STOPPED} ] || exit 0
47while read -r sid; do
48 case "$sid" in ''|*[!0-9]*|0|1) continue ;; esac
49 pkill -CONT -s "$sid"
50done < ${STOPPED}
51rm -f ${STOPPED}`
52// A record whose owner (the Claude process that paused, by pid and start
53// time, so a reused pid does not count) is gone was left by a crash or a kill:
54// its tasks would stay stopped forever. Continue them and drop the record.
55// Thaw files older than a week are pruned. Prints how many sessions resumed.
56const RECOVER_SH = `n=0
57for f in ${DIR}/*.stopped; do
58 [ -e "$f" ] || continue
59 read -r _ _ pid start < "$f"
60 if [ -n "$pid" ] && [ "$(ps -o lstart= -p "$pid" 2>/dev/null | tr -s ' ' '_')" = "$start" ]; then
61 continue
62 fi
63 while read -r sid; do
64 case "$sid" in ''|*[!0-9]*|0|1) continue ;; esac
65 pkill -CONT -s "$sid" && n=$((n + 1))
66 done < "$f"
67 rm -f "$f"
68done
69find ${DIR} -name '*.thaw' -mtime +7 -delete 2>/dev/null
70echo "$n"`
71const CLEAN_SH = `${RESUME_SH}
72rm -f ${FILE}`
73const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
74
75// Tools that only host other loops: their inner steps and tool calls are
76// counted themselves, so counting the host would never let a freeze settle.
77const CONTAINER_TOOLS = new Set(['Agent', 'Task', 'Workflow'])
78
79// After a resume, an MCP call failing like this most likely hit a connection
80// that dropped while frozen (network change, expired session).
81const MCP_WATCH_MS = 10 * 60_000
82const CONNECTION_ERROR =
83 /ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|fetch failed|not connected|disconnected|connection (closed|lost|refused|reset)|session (expired|not found)|\b401\b|unauthori[sz]ed|re-?auth/i
84
85const IDLE: FreezeState = {
86 isFrozen: false,
87 since: null,
88 parked: 0,
89 running: 0,
90 paused: 0,
91 resumedAt: null,
92}
93const state = atom({ plugin: 'freeze', key: 'state' } as const, IDLE)
94
95// Prompts the session starts by itself (a /loop or routine firing, a task
96// notification) that arrived while frozen: an identical one is held once.
97const SELF_STARTED = new Set(['scheduled-trigger', 'task-notification'])
98let heldPrompts = new Set<string>()
99
100// Set from the options at each (re)load.
101let shouldPauseBackground = true
102let toggleHint = '/freeze'
103let hasBand = false
104// Whether the main thread's turn is running, i.e. its spinner is on screen.
105let isWorking = false
106
107const hhmm = (ms: number | null) =>
108 ms === null ? '' : new Date(ms).toTimeString().slice(0, 5)
109
110const isSettled = (s: FreezeState) => s.isFrozen && s.running === 0
111
112// One full line at a time: the spinner while a turn runs, else the band when
113// the shortcut is on, else the status line.
114function statusText(s: FreezeState) {
115 if (!s.isFrozen || isWorking || hasBand) {
116 return undefined
117 }
118
119 return stateLine(s)
120}
121
122async function refreshStatus($: EngineInterface) {
123 $.ui.status(statusText(await read($, state)))
124}
125
126function stateLine(s: FreezeState) {
127 return isSettled(s)
128 ? frozenLine(s)
129 : `❄ Freezing · ${s.running} still running · ${toggleHint} to cancel`
130}
131
132// "❄ Frozen since 17:49 · safe to disconnect · 2 background paused · /freeze to resume"
133function frozenLine(s: FreezeState) {
134 const paused = s.paused > 0 ? ` · ${s.paused} background paused` : ''
135
136 return `❄ Frozen since ${hhmm(s.since)} · safe to disconnect${paused} · ${toggleHint} to resume`
137}
138
139// Pausing, resuming and toggling run one at a time, so a late pause can never
140// stop processes after a resume, and quick presses cannot interleave.
141let queue: Promise<unknown> = Promise.resolve()
142
143function serial<T>(work: () => Promise<T>) {
144 const run = queue.then(work)
145 queue = run.catch(() => undefined)
146
147 return run
148}
149
150async function change($: EngineInterface, fn: (s: FreezeState) => FreezeState) {
151 let before = IDLE
152 // State kept from an older version of the mod may lack newer fields.
153 const next = await update($, state, s => {
154 before = { ...IDLE, ...s }
155
156 return fn(before)
157 })
158 $.ui.status(statusText(next))
159 if (shouldPauseBackground && !isSettled(before) && isSettled(next)) {
160 void serial(() => pauseBackground($))
161 }
162
163 return next
164}
165
166async function sessionId($: EngineInterface) {
167 const id = await $.session.id()
168 if (!SESSION_ID.test(id)) {
169 throw new Error(`unexpected session id ${JSON.stringify(id)}`)
170 }
171
172 return id
173}
174
175async function sh($: EngineInterface, script: string) {
176 const { exitCode, stdout, stderr } = await $.process.run([
177 'sh',
178 '-c',
179 script,
180 'sh',
181 await sessionId($),
182 ])
183 if (exitCode !== 0) {
184 throw new Error(stderr.trim() || `exit ${exitCode}`)
185 }
186
187 return stdout.trim()
188}
189
190async function pauseBackground($: EngineInterface) {
191 if (!isSettled(await read($, state))) {
192 return
193 }
194 try {
195 const out = await sh($, PAUSE_SH)
196 if (out === 'unsupported') {
197 $.ui.toast('pausing background tasks needs Linux (/proc); they keep running')
198
199 return
200 }
201 const paused = Number(out) || 0
202 await change($, s => ({ ...s, paused }))
203 } catch (error) {
204 $.ui.toast(`could not pause background tasks (${error instanceof Error ? error.message : String(error)})`)
205 }
206}
207
208async function freeze($: EngineInterface) {
209 // File first, then the flag: a waiter never sees "frozen" + thaw file.
210 await sh($, FREEZE_SH)
211 const since = await $.clock.now()
212 heldPrompts = new Set()
213 await change($, s => ({ ...s, isFrozen: true, since }))
214}
215
216async function thaw($: EngineInterface) {
217 // Background tasks first, then the waiters. If the thaw file cannot be
218 // written, the session stays honestly frozen.
219 await sh($, RESUME_SH)
220 await sh($, THAW_SH)
221 const resumedAt = await $.clock.now()
222 await change($, s => ({ ...s, isFrozen: false, since: null, paused: 0, resumedAt }))
223}
224
225// `/freeze shortcut on`: binds ctrl+x f in ~/.claude/keybindings.json (merged,
226// never overwriting another binding or a file that is not valid JSON) and
227// turns the option on, which reloads the mod with its band. `off` turns the
228// option off and leaves the binding (inert without the band).
229async function setShortcut($: EngineInterface, isOn: boolean) {
230 if (isOn) {
231 const problem = await bindShortcutKey($)
232 if (problem) {
233 return problem
234 }
235 }
236 const result = await $.config.set({ key: `${$.plugin.name}.shortcut`, value: isOn })
237 if ('deny' in result && result.deny) {
238 return `could not change the shortcut option (${result.deny}). Set it in /config.`
239 }
240
241 return isOn
242 ? `Shortcut on: ${KEY_LABEL} toggles freeze (bound in ~/.claude/keybindings.json).`
243 : `Shortcut off. The ${KEY_LABEL} binding stays in ~/.claude/keybindings.json, inert without the band.`
244}
245
246async function bindShortcutKey($: EngineInterface) {
247 const home = (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout
248 const path = `${home}/.claude/keybindings.json`
249 const exists = (await $.process.run(['test', '-e', path])).exitCode === 0
250 let doc: { bindings?: unknown } = {}
251 if (exists) {
252 try {
253 doc = JSON.parse(await $.fs.read(path))
254 } catch {
255 return `${path} is not valid JSON, left untouched. Add the binding by hand (README).`
256 }
257 }
258 const blocks = Array.isArray(doc.bindings) ? (doc.bindings as KeyBlock[]) : []
259 const chat = blocks.find(b => b?.context === 'Chat')
260 const current = chat?.bindings?.[KEY_LABEL]
261 if (current === KEY_ACTION) {
262 return undefined
263 }
264 if (current) {
265 return `${KEY_LABEL} is already bound to ${current} in ${path}, left untouched.`
266 }
267 const nextChat: KeyBlock = {
268 context: 'Chat',
269 bindings: { ...(chat?.bindings ?? {}), [KEY_LABEL]: KEY_ACTION },
270 }
271 const nextDoc = {
272 $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
273 $docs: 'https://code.claude.com/docs/en/keybindings',
274 ...doc,
275 bindings: chat ? blocks.map(b => (b === chat ? nextChat : b)) : [...blocks, nextChat],
276 }
277 await $.fs.write(path, `${JSON.stringify(nextDoc, null, 2)}\n`)
278
279 return undefined
280}
281
282type KeyBlock = { context?: string; bindings?: Record<string, string | null> }
283
284function toggle($: EngineInterface) {
285 return serial(async () => {
286 const { isFrozen } = await read($, state)
287 try {
288 await (isFrozen ? thaw($) : freeze($))
289
290 return !isFrozen
291 } catch (error) {
292 const reason = error instanceof Error ? error.message : String(error)
293 $.ui.toast(`could not ${isFrozen ? 'resume' : 'freeze'} (${reason})`)
294
295 return isFrozen
296 }
297 })
298}
299
300// Waits until resumed. The child is tied to the dispatch: Esc (`signal`)
301// kills it at once. A child that fails (cannot start, exits non-zero) is not
302// a resume: the mod fails open and clears the state, so the UI never claims
303// FROZEN while the session runs.
304async function park($: EngineInterface, signal: AbortSignal) {
305 await change($, s => ({ ...s, parked: s.parked + 1 }))
306 try {
307 const wait = $.process.spawn({ argv: ['sh', '-c', WAIT_SH, 'sh', await sessionId($)] })
308 const pieces = wait[Symbol.asyncIterator]()
309 let piece = await pieces.next()
310 while (!piece.done && !signal.aborted) {
311 piece = await pieces.next()
312 }
313 if (signal.aborted || (piece.done && piece.value?.code === 0)) {
314 return
315 }
316 throw new Error(`wait ended with ${JSON.stringify(piece.value)}`)
317 } catch (error) {
318 if (!signal.aborted) {
319 await serial(() => sh($, RESUME_SH)).catch(() => undefined)
320 await change($, s => ({ ...s, isFrozen: false, since: null, paused: 0 }))
321 $.ui.toast(`could not hold the session, resumed (${error instanceof Error ? error.message : String(error)})`)
322 }
323 } finally {
324 await change($, s => ({ ...s, parked: Math.max(0, s.parked - 1) }))
325 }
326}
327
328const running = (delta: number) => (s: FreezeState) => ({
329 ...s,
330 running: Math.max(0, s.running + delta),
331})
332
333// The gate in front of every step and tool call. It counts the work first and
334// checks the flag second, so a freeze landing in between shows "1 still
335// running" rather than "safe to disconnect". Resolves false when the dispatch
336// was abandoned (Esc) while parked: the caller must not run anything then.
337async function gate($: EngineInterface, signal: AbortSignal) {
338 await change($, running(1))
339 if (!(await read($, state)).isFrozen) {
340 return true
341 }
342
343 await change($, running(-1))
344 await park($, signal)
345 if (signal.aborted) {
346 return false
347 }
348 await change($, running(1))
349
350 return true
351}
352
353// Names the MCP server whose call failed on a dropped connection, once per
354// server, for a while after a resume.
355const warnedServers = new Set<string>()
356
357async function checkMcpCall($: EngineInterface, tool: string, outcome: unknown) {
358 const { resumedAt } = await read($, state)
359 if (resumedAt === null || (await $.clock.now()) - resumedAt > MCP_WATCH_MS) {
360 return
361 }
362 const failure =
363 outcome instanceof Error
364 ? outcome.message
365 : (outcome as { isError?: boolean }).isError
366 ? JSON.stringify(outcome)
367 : ''
368 const server = tool.split('__')[1] ?? tool
369 if (!CONNECTION_ERROR.test(failure) || warnedServers.has(server)) {
370 return
371 }
372 warnedServers.add(server)
373 $.ui.toast(`MCP server "${server}" lost its connection while frozen, run /mcp to reconnect`)
374}
375
376export const register: Register = (on, options) => {
377 const hasShortcut = options.shortcut === true
378 shouldPauseBackground = options.pauseBackground !== false
379 toggleHint = hasShortcut ? `/freeze or ${KEY_LABEL}` : '/freeze'
380 hasBand = hasShortcut
381 isWorking = false
382
383 on('turn.start', async ($, e, next) => {
384 isWorking = true
385 await refreshStatus($)
386
387 return next(e)
388 })
389
390 on('turn.complete', async ($, e, next) => {
391 if (e.agentId === undefined) {
392 isWorking = false
393 await refreshStatus($)
394 }
395
396 return next(e)
397 })
398
399 on('session.start', async ($, e, next) => {
400 await $.command.register({
401 name: 'freeze',
402 description: 'Freeze or resume this session at the next safe boundary',
403 argumentHint: '[shortcut on|off]',
404 immediate: true,
405 })
406 // Fires again on every reload: counters from a previous load are stale,
407 // and nothing may stay stopped unless the session is frozen.
408 const s = await change($, prev => ({ ...prev, parked: 0, running: 0 }))
409 if (!s.isFrozen) {
410 await serial(() => sh($, RESUME_SH)).catch(() => undefined)
411 }
412 const recovered = Number(await sh($, RECOVER_SH).catch(() => '0')) || 0
413 if (recovered > 0) {
414 $.ui.toast(`resumed ${recovered} background task(s) left paused by a session that ended`)
415 }
416
417 return next(e)
418 })
419
420 on('session.end', async ($, e, next) => {
421 // Never leave background tasks stopped behind a closed session.
422 await sh($, CLEAN_SH).catch(() => undefined)
423
424 return next(e)
425 })
426
427 on('command.run', { command: 'freeze' }, async ($, e) => {
428 const args = e.args.trim().toLowerCase()
429 if (args === 'shortcut on' || args === 'shortcut off') {
430 return { text: await setShortcut($, args === 'shortcut on') }
431 }
432 if (args !== '') {
433 return { text: 'Usage: /freeze (toggle) · /freeze shortcut on|off' }
434 }
435
436 return {
437 text: (await toggle($))
438 ? '❄ Freezing. Running work finishes, then everything parks. /freeze again to resume.'
439 : 'Resumed.',
440 }
441 })
442
443 on('prompt.submit', async ($, e, next) => {
444 if (SELF_STARTED.has(e.origin.kind) && (await read($, state)).isFrozen) {
445 const key = `${e.origin.kind}:${e.text}`
446 if (heldPrompts.has(key)) {
447 return { drop: 'the same prompt is already held until you resume' }
448 }
449 heldPrompts = new Set([...heldPrompts, key])
450 }
451
452 return next(e)
453 })
454
455 on('tool.call', async ($, e, next) => {
456 if (CONTAINER_TOOLS.has(e.tool)) {
457 // Not counted (its inner loop is gated step by step), only held.
458 if ((await read($, state)).isFrozen) {
459 await park($, next.signal)
460 }
461
462 return next.signal.aborted
463 ? { deny: 'Interrupted while the session was frozen.' }
464 : next(e)
465 }
466
467 if (!(await gate($, next.signal))) {
468 // Interrupted while parked: never run a tool of an abandoned turn.
469 return { deny: 'Interrupted while the session was frozen.' }
470 }
471 try {
472 const outcome = await next(e)
473 if (e.tool.startsWith('mcp__')) {
474 await checkMcpCall($, e.tool, outcome)
475 }
476
477 return outcome
478 } catch (error) {
479 if (e.tool.startsWith('mcp__')) {
480 await checkMcpCall($, e.tool, error)
481 }
482 throw error
483 } finally {
484 await change($, running(-1))
485 }
486 })
487
488 on('turn.step', async function* ($, e, next) {
489 if (!(await gate($, next.signal))) {
490 // Interrupted while parked: answer for the step with "no request was
491 // made", so nothing is sent for an abandoned turn (a bare return is
492 // refused by the engine, which then skips the hook).
493 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: null, usage: null }
494 }
495 try {
496 return yield* next(e)
497 } finally {
498 await change($, running(-1))
499 }
500 })
501
502 // The line that animates while a turn runs (`Swooping… (3m 1s …)`). While
503 // work is still in flight it keeps animating, honestly, as "Freezing…"; once
504 // settled it is replaced whole, timer included, by a static frozen line.
505 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
506 const s = await read($, state)
507 if (!s.isFrozen) {
508 return next(e)
509 }
510 if (!isSettled(s)) {
511 const message = `Freezing · ${s.running} still running · ${toggleHint} to cancel`
512
513 return next({ ...e, props: { ...e.props, message, suffix: '' } })
514 }
515
516 const { Text } = $.ui.resolve(e)
517
518 return <Text color="cyan">{frozenLine(s)}</Text>
519 })
520
521 if (!hasShortcut) {
522 return
523 }
524
525 // The band exists only for the shortcut: its Button is what the borrowed
526 // keybinding action presses. Without the option, the status line suffices.
527 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528 if (e.props.hasSurvey) {
529 return next(e)
530 }
531
532 const s = await read($, state)
533 const { Box, Button } = $.ui.resolve(e)
534 // While a turn runs, the spinner carries the state: the band is only the
535 // button. Idle, the band is the one full line.
536 const action = isSettled(s) ? 'resume' : 'cancel'
537 const label = !s.isFrozen
538 ? `❄ ${KEY_LABEL} freeze`
539 : e.props.isWorking
540 ? `❄ ${KEY_LABEL} ${action}`
541 : stateLine(s)
542
543 return (
544 <Box>
545 <Button
546 key="toggle"
547 plain
548 dimColor={!s.isFrozen}
549 action={KEY_ACTION}
550 label={label}
551 onPress={() => void toggle($)}
552 />
553 </Box>
554 )
555 })
556}
557types/index.d.ts 15 lines1export type FreezeState = {
2 isFrozen: boolean
3 since: number | null
4 parked: number
5 running: number
6 paused: number
7 resumedAt: number | null
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 freeze: { state: FreezeState }
13 }
14}
15