SLOPSHOPPER

freeze

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

newbandspinnerguardcommandtoast
v0.1.0MITupdated 2026-10-05francktrouillez/claude-freeze
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · freeze
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /freeze ⎿ freeze: ❄ Freezing. Running work finishes, then everything parks. /freeze again to resume. ❄ Frozen since 01:53 · safe to disconnect · /freeze to resume ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ freeze: ❄ Frozen since 01:53 · safe to disconnect · /freeze to resume

Draws

Spinner
❄ Frozen since 01:53 · safe to disconnect · /freeze to resume
README

<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

What it does

  • Freezes at a safe boundary. A model reply that is streaming or a tool that is running finishes first; nothing is killed or retried.
  • Freezes everything. The main thread, subagents and background agents all park at their next model request or tool call.
  • Stops the spinner. Once parked, the animated line and its timer give way to a static ❄ Frozen since … line, so the session no longer looks busy.
  • Holds self-started turns. /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.
  • Tells you when it is safe. The band and the status line say 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.
  • Pauses background tasks too (Linux). Commands started in the background, monitors and dev servers are paused once the freeze settles and continue when you resume, so nothing fails or keeps working behind your back while you are offline.
  • Names the MCP servers that dropped. If an MCP call fails on a lost connection shortly after you resume, a toast tells you which server to reconnect with /mcp.
  • Per session. Each Claude Code session freezes on its own.

Install

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).

Option A: as a plugin (recommended)

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

Option B: from a clone (for hacking on it)

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.

Options

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": { … } } }.

OptionDefaultWhat it does
pauseBackgroundonPause this session's background tasks while frozen (Linux).
shortcutoffShow the band above the prompt with the Ctrl+X F button.

Optional: the Ctrl+X F shortcut

Off 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.

Usage

ActionHow
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 disconnectwait for safe to disconnect
Cancel a freeze that is still settling/freeze again
Abandon the parked turnEsc: 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.

How it works

  • The gate. Hooks on 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.
  • The hold. A mod hook has a 10 s budget of its own time, but time spent in a $ 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.
  • Failing open, honestly. If the wait cannot run or exits with an error, the mod resumes the session rather than hang it, clears the frozen state (so nothing claims FROZEN while work runs) and shows a toast.
  • The in-flight counter. Each model request and tool call is counted before the gate checks the flag, and for as long as it runs. Tools that only host other loops (Agent, Task, Workflow) are not counted, since their inner steps are. freezing… turns into safe to disconnect when the count reaches zero.
  • Background tasks. Once the freeze settles (nothing in flight), the mod finds the processes whose output goes to this session's task files. Claude Code starts each background task in a session of its own, so the mod pauses that whole session (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.
  • Repeated wakeups. While frozen, a self-started prompt (a /loop or routine firing, a task notification) identical to one already held is dropped. Prompts you type are never touched.
  • Dropped MCP connections. For ten minutes after a resume, an MCP tool call that fails with a connection-type error (reset, timeout, not connected, 401) raises one toast per server: MCP server "x" lost its connection while frozen, run /mcp to reconnect.
  • One line at a time. The spinner carries the state while a turn runs; when idle, the band does (shortcut on) or else the status line.
  • The band (shortcut option only). A one-line band above the prompt carries the button the shortcut presses.

Security

  • The mod has no network access and reads nothing from your conversation.
  • It never changes a tool call or a model request. It only delays them, and drops a step you interrupt with Esc while frozen.
  • It runs a few fixed shell commands on your machine, and only signals the background tasks of the session it runs in.

Found a vulnerability? Please report it privately through the repository's Security tab (Report a vulnerability) rather than in a public issue.

Known limitations

  • The [-] 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.
  • The band sits above the prompt. Below the prompt, a mod can only append text to the hint line (where the permission mode shows) or replace that whole line, losing Claude Code's own live indicators. The shortcut needs a button, and the band is the place for it.
  • Paused is not reconnected. Pausing stops background tasks from working and failing while you are offline, but a connection a task held open can still be reset by a network change; it sees that when it continues (many tools retry).
  • Background pausing is Linux-only (it reads /proc). On macOS the tasks keep running while frozen, and a toast says so.
  • A paused dev server does not answer until you resume. Turn pauseBackground off if you want it to keep serving while frozen.
  • MCP connections are not reconnected by the mod, which can only reach its own servers. Claude Code reconnects remote servers (sometimes asking to re-authenticate); the mod's toast tells you which one to look at.
  • Subagents are assumed to share the session id (they read the same thaw file). Verified for the main thread; for subagents it follows from the API, not a test.
  • The turn's clock still counts the frozen time. The mod hides the timer while frozen, but the elapsed time shown after you resume (and the closing … for 12m 3s line) includes the time spent frozen.
  • The prompt cache expires after about an hour. Resuming after a long freeze re-reads the whole conversation once, at full input price.

Development

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

Contributing

Contributions are welcome: bug reports, ideas and pull requests.

  • Issues: say what you did, what you expected and what happened, with your Claude Code version (claude --version) and OS.
  • Pull requests: keep them focused, add a test for new behaviour, and make sure claude plugin validate . and claude plugin test . pass. Mods run on an early-access API, so mention the Claude Code version you tested with.
  • Not sure about an idea? Open an issue first to talk it through.

License

MIT

Source 2 files
hooks/register.tsx 557 lines
1import { 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}
557
types/index.d.ts 15 lines
1export 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