SLOPSHOPPER

hot-upgrade

/hot-upgrade installs the latest Claude Code and resumes the same session on it at the next safe point

newcommandtoaststatusprocesstimer
v0.1.0MITupdated 2026-10-24kqesar/claude-mods/hot-upgrade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hot-upgrade
› 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 › /hot-upgrade ⎿ hot-upgrade: Claude Code installed (running 2.1.289). No wrapper to restart through: the resume command goes to your clipb ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

hot-upgrade

Claude Code updates itself in the background, but the session you have open keeps running the old version until you quit. And quitting in the middle of work is exactly what you don't want to do.

So: type /hot-upgrade and keep working. It installs the new version, waits for Claude to be done with whatever it is doing, then restarts on the same conversation. You lose two or three seconds, not your context.

> /hot-upgrade
Claude Code 2.1.311 installed (running 2.1.310). Restarting once nothing
runs: 1 agent running. `/hot-upgrade now` restarts regardless, `/hot-upgrade cancel`
keeps this version.

  ↻ 2.1.311 waits: 1 agent running

"Nothing runs" means no turn in progress, no subagent, no background task and no scheduled wakeup (/loop, reminders). Until then the status line tells you what it is waiting for.

CommandWhat it does
/hot-upgradeInstall the latest version and switch to it at the next safe moment
/hot-upgrade setupInstall the shell wrapper the restart goes through (once)
/hot-upgrade nowSwitch as soon as the current turn ends, even if a background task or a /loop gets cut
/hot-upgrade cancelForget the pending switch; the new version starts with your next session

Setup

Two steps, both from inside Claude Code:

/plugin install hot-upgrade --marketplace kqesar/claude-mods
/hot-upgrade setup

/hot-upgrade setup detects your shell and installs a small claude wrapper function:

  • fish: ~/.config/fish/functions/claude.fish
  • bash / zsh: ~/.config/claude-mods/claude.sh, plus one line in ~/.bashrc or ~/.zshrc that sources it (added only once)

Then open a new terminal and start claude as usual. That's it.

Prefer doing it by hand? The wrapper is in wrapper/: source claude.sh from your rc file, or copy claude.fish to ~/.config/fish/functions/. Without any wrapper the mod still installs the update, but it can't restart for you: it copies the claude --resume <id> command to your clipboard instead.

How it works

A running process keeps the binary it was started with, so there is no way to swap versions under a live session. The trick is to make the restart cheap instead.

The wrapper creates an empty temp file and hands its path to Claude Code in CLAUDE_RELAUNCH_FILE. When the mod decides it is safe, it writes the session id into that file and runs /exit. The wrapper sees a non-empty file and runs claude --resume <id>. It loops, so a second /hot-upgrade later works the same way. When the file stays empty, the wrapper just returns Claude's exit code.

sequenceDiagram
    participant W as claude() shell function
    participant C as Claude Code (old)
    participant M as hot-upgrade mod
    participant N as Claude Code (new)
    W->>C: CLAUDE_RELAUNCH_FILE=/tmp/claude-relaunch.X claude ...
    C->>M: /hot-upgrade
    M->>M: claude update, then wait until nothing runs
    M->>W: write the session id into the file
    M->>C: /exit
    W->>N: claude --resume <id>
    Note over W,N: same conversation, new binary

The mod learns what is still running from three places: turn.start and turn.complete for the current turn, $.agent.list() for subagents, and the Stop hook's background_tasks and session_crons for the rest.

Good to know

  • The restart is a plain claude --resume <id>: flags you started with (--model, --permission-mode...) are not passed again.
  • Remote Control has to be turned on again after the restart.
  • Why not /update? Claude Code reserves that name, so a plugin can't register it, and the command menu completes it to /update-config anyway.
Source 2 files
hooks/register.ts 234 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// A running process keeps the binary it started with: switching versions means
5// a restart. This mod picks the moment (nothing running that a restart would
6// kill), records the session id for the shell wrapper and exits; the wrapper
7// resumes the same session on the new binary.
8const pending = atom({ plugin: 'hot-upgrade', key: 'pending' } as const, null)
9const isForced = atom({ plugin: 'hot-upgrade', key: 'isForced' } as const, false)
10const background = atom({ plugin: 'hot-upgrade', key: 'background' } as const, {
11  tasks: 0,
12  crons: 0,
13})
14
15const BUSY_AGENT = new Set(['pending', 'running', 'waiting'])
16// Leaves the command's own output on screen before the exit.
17const GRACE_MS = 800
18
19const plural = (count: number, word: string) => `${count} ${word}${count > 1 ? 's' : ''}`
20
21// What a restart would kill now; empty means it is safe.
22async function blockersOf($: EngineInterface, isTurnRunning: boolean): Promise<string[]> {
23  const reasons: string[] = []
24
25  if (isTurnRunning) {
26    reasons.push('a turn is running')
27  }
28  if (await read($, isForced)) {
29    return reasons
30  }
31  const agents = (await $.agent.list()).filter(agent => BUSY_AGENT.has(agent.status))
32  const { tasks, crons } = await read($, background)
33
34  if (agents.length > 0) {
35    reasons.push(plural(agents.length, 'agent') + ' running')
36  }
37  if (tasks > 0) {
38    reasons.push(plural(tasks, 'background task'))
39  }
40  if (crons > 0) {
41    reasons.push(plural(crons, 'scheduled wakeup') + ' a restart would drop')
42  }
43
44  return reasons
45}
46
47async function installedVersion($: EngineInterface): Promise<string | undefined> {
48  const { exitCode, stdout } = await $.process.run(['claude', '--version'])
49
50  return exitCode === 0 ? stdout.trim().split(/\s+/)[0] : undefined
51}
52
53const RC_LINE =
54  '[ -f "$HOME/.config/claude-mods/claude.sh" ] && . "$HOME/.config/claude-mods/claude.sh"'
55
56// Installs the shell wrapper the mod restarts through, for the user's shell.
57async function setup($: EngineInterface): Promise<string> {
58  if ((await $.env.get('CLAUDE_RELAUNCH_FILE')) !== undefined) {
59    return 'The wrapper is already active: /hot-upgrade restarts on its own.'
60  }
61  const home = await $.env.get('HOME')
62  const shell = ((await $.env.get('SHELL')) ?? '').split('/').pop() ?? ''
63
64  if (home === undefined) {
65    return 'HOME is not set: install the wrapper by hand (see the README).'
66  }
67  const wrapper = (name: string) => $.fs.read(`${$.plugin.root}/wrapper/${name}`)
68  const next = 'Open a new terminal and start `claude` there: /hot-upgrade will then restart on its own.'
69
70  if (shell === 'fish') {
71    const target = `${home}/.config/fish/functions/claude.fish`
72    const isThere = await $.fs.read(target).then(() => true, () => false)
73
74    if (isThere) {
75      return `~/.config/fish/functions/claude.fish already exists; left as is. ${next}`
76    }
77    await $.fs.write(target, await wrapper('claude.fish'))
78
79    return `Wrapper installed in ~/.config/fish/functions/claude.fish. ${next}`
80  }
81  if (shell === 'bash' || shell === 'zsh') {
82    const rc = `${home}/.${shell}rc`
83    const current = await $.fs.read(rc).catch(() => '')
84    await $.fs.write(`${home}/.config/claude-mods/claude.sh`, await wrapper('claude.sh'))
85
86    if (!current.includes(RC_LINE)) {
87      const block = `\n# Resume the Claude Code session after /hot-upgrade (kqesar/claude-mods)\n${RC_LINE}\n`
88      await $.fs.write(rc, current + block)
89    }
90
91    return `Wrapper installed in ~/.config/claude-mods/claude.sh, sourced from ~/.${shell}rc. ${next}`
92  }
93
94  return `No wrapper for ${shell || 'this shell'} yet: bash, zsh and fish are covered (see the README).`
95}
96
97async function relaunch($: EngineInterface) {
98  const version = await read($, pending)
99  await update($, pending, () => null)
100  await update($, isForced, () => false)
101  $.ui.status(undefined)
102  const id = await $.session.id()
103  const file = await $.env.get('CLAUDE_RELAUNCH_FILE')
104
105  if (file === undefined || file === '') {
106    const command = `claude --resume ${id}`
107    await $.ui.copy({ text: command })
108    $.ui.toast(`hot-upgrade: ${version} is installed; restart with \`${command}\` (copied). /hot-upgrade setup makes it automatic`)
109
110    return
111  }
112  await $.fs.write(file, id)
113  $.ui.toast(`hot-upgrade: restarting on ${version}`)
114  await $.command.run({ command: 'exit' })
115}
116
117// Relaunches when nothing blocks, else says what it waits for.
118async function attempt($: EngineInterface, isTurnRunning: boolean): Promise<string[]> {
119  const version = await read($, pending)
120
121  if (version === null) {
122    return []
123  }
124  const reasons = await blockersOf($, isTurnRunning)
125
126  if (reasons.length === 0) {
127    await relaunch($)
128  } else {
129    $.ui.status(`↻ ${version} waits: ${reasons.join(', ')}`)
130  }
131
132  return reasons
133}
134
135export const register: Register = on => {
136  let isTurnRunning = false
137
138  on('session.start', async ($, e, next) => {
139    await $.command.register({
140      name: 'hot-upgrade',
141      description: 'Switch to the latest Claude Code and resume this session when nothing runs',
142      argumentHint: '[setup | now | cancel]',
143    })
144
145    return next(e)
146  })
147
148  // `/update` is a name Claude Code reserves: it cannot be registered, and the
149  // typeahead completes it to another command, but typed in full it lands here.
150  for (const command of ['hot-upgrade', 'update']) {
151    on('command.run', { command }, async ($, e) => {
152      const arg = (e.args ?? '').trim()
153
154      if (arg === 'setup') {
155        return { text: await setup($) }
156      }
157      if (arg === 'cancel') {
158        await update($, pending, () => null)
159        await update($, isForced, () => false)
160        $.ui.status(undefined)
161
162        return { text: 'Update switch cancelled; the new version starts with the next session.' }
163      }
164      const running = (await $.session.version()).version
165      const updated = await $.process.run(['claude', 'update'], { timeoutMs: 600_000 })
166
167      if (updated.exitCode !== 0) {
168        const detail = (updated.stderr || updated.stdout).trim().split('\n').slice(-3).join('\n')
169
170        return { text: `claude update failed (exit ${updated.exitCode}):\n${detail}` }
171      }
172      const installed = await installedVersion($)
173
174      if (installed === undefined || installed === running) {
175        return { text: `Claude Code ${running} is up to date.` }
176      }
177      await update($, pending, () => installed)
178      await update($, isForced, () => arg === 'now')
179      const reasons = await blockersOf($, false)
180
181      if (reasons.length > 0) {
182        $.ui.status(`↻ ${installed} waits: ${reasons.join(', ')}`)
183
184        return {
185          text:
186            `Claude Code ${installed} installed (running ${running}). ` +
187            `Restarting once nothing runs: ${reasons.join(', ')}. ` +
188            '`/hot-upgrade now` restarts regardless, `/hot-upgrade cancel` keeps this version.',
189        }
190      }
191      $.clock.after(GRACE_MS, () => void attempt($, isTurnRunning))
192      const hasWrapper = (await $.env.get('CLAUDE_RELAUNCH_FILE')) !== undefined
193
194      return {
195        text: hasWrapper
196          ? `Claude Code ${installed} installed (running ${running}): restarting on it.`
197          : `Claude Code ${installed} installed (running ${running}). No wrapper to restart ` +
198            'through: the resume command goes to your clipboard. `/hot-upgrade setup` fixes that.',
199      }
200    }).catch(($, e, next) =>
201      next.called ? next(e) : { text: `hot-upgrade: the command failed; nothing was changed.` },
202    )
203  }
204
205  on('turn.start', ($, e, next) => {
206    if (e.agentId === undefined) {
207      isTurnRunning = true
208    }
209
210    return next(e)
211  })
212
213  // The Stop hook is the one place that reports background work and crons.
214  on('classic.Stop', async ($, e, next) => {
215    await update($, background, () => ({
216      tasks: e.background_tasks?.length ?? 0,
217      crons: e.session_crons?.length ?? 0,
218    }))
219
220    return next(e)
221  }).catch(($, e, next) => next(e))
222
223  on('turn.complete', async ($, e, next) => {
224    const result = await next(e)
225
226    if (e.agentId === undefined) {
227      isTurnRunning = false
228      $.clock.after(GRACE_MS, () => void attempt($, isTurnRunning))
229    }
230
231    return result
232  })
233}
234
types/index.d.ts 14 lines
1// What the last turn left running, as its Stop hook reported it.
2export type Background = { tasks: number; crons: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'hot-upgrade': {
7      // The installed version waiting for a safe point to be switched to.
8      pending: string | null
9      isForced: boolean
10      background: Background
11    }
12  }
13}
14