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

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.
| Command | What it does |
|---|---|
/hot-upgrade | Install the latest version and switch to it at the next safe moment |
/hot-upgrade setup | Install the shell wrapper the restart goes through (once) |
/hot-upgrade now | Switch as soon as the current turn ends, even if a background task or a /loop gets cut |
/hot-upgrade cancel | Forget the pending switch; the new version starts with your next session |
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:
~/.config/fish/functions/claude.fish~/.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.
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.
claude --resume <id>: flags you started with (--model, --permission-mode...) are not passed again./update? Claude Code reserves that name, so a plugin can't register it, and the command menu completes it to /update-config anyway.hooks/register.ts 234 lines1import { 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}
234types/index.d.ts 14 lines1// 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