/relaunch restarts this Claude Code session in a new terminal tab with --resume <id>, so MCP servers added since it started are loaded.

/relaunch restarts the current Claude Code session in a new terminal tab, so MCP servers you added since it started get loaded. It resumes this exact session by id, so it works even with several sessions open.
Works on Windows, macOS and Linux. Needs Claude Code 2.1.287 or later.
/plugin marketplace add lperezmo/session-relaunch
/plugin install session-relaunch@session-relaunch
/relaunch asks: relaunch now, compact first, or open the tab and stay
/relaunch now [note] reopen this session in a new tab and exit this one
/relaunch compact [note] compact first (the note steers the summary), then relaunch
/relaunch stay [note] open the new tab; it starts once you /exit here
/relaunch help show this
The note is typed into the new session's prompt box. When a tool call adds or removes an MCP server, the mod prints a one-line reminder to /relaunch.
One catch: if you started the session with a prompt (claude "do X"), that prompt is part of the command line and gets sent again on relaunch.
/exit after opening the new tab (and /compact with compact).powershell.exe with hooks/relaunch.ps1 on Windows, bash with hooks/relaunch.sh elsewhere. Each opens a terminal tab (Windows Terminal, tmux, iTerm, Terminal.app, GNOME Terminal and others) running claude --resume <session id> with your original flags. Along the way they may call wt.exe, tmux, osascript (macOS asks permission once), ps or python3 (macOS, to read the flags)./relaunch command, and after Bash, PowerShell, Write and Edit tool calls a check for MCP config changes. It never changes a call or its result.claude process's command line (to reuse its flags; they are shown in /relaunch's reply). No credentials.MIT
hooks/register.ts 372 lines1/**
2 * session-relaunch: `/relaunch` restarts this session in a new terminal tab
3 * with `claude --resume <id>`, so MCP servers added since it started load.
4 *
5 * Steps: optionally compact, have relaunch.ps1 (Windows) or relaunch.sh open
6 * a tab that waits for this claude to exit and then resumes the same session
7 * id with the same flags
8 * (plus the current model when /model changed it), leave a record in the
9 * store for the new process to welcome itself back with, then exit this one.
10 * Resuming by id rather than `--continue` picks the right session when
11 * several are open at once.
12 *
13 * Also nudges the person (never the model) when a tool call changes the MCP
14 * configuration, since that is exactly when a relaunch is needed.
15 *
16 * Every `$.noun.verb(...)` is written literally here; the loader inventories
17 * them statically. The helpers in parse.ts and mcp-change.ts take no `$`.
18 */
19
20import type { EngineInterface, On } from 'claude-code'
21
22import { mcpChange } from './mcp-change'
23import {
24 asPending,
25 commandLine,
26 isFresh,
27 isModelFlag,
28 isWaiting,
29 isWindowsPath,
30 parseArgs,
31 parseLaunch,
32 PENDING_PREFIX,
33 PENDING_STAY_TTL_MS,
34 PENDING_TTL_MS,
35 type Mode,
36 type PendingRecord,
37} from './parse'
38
39const COMMAND_NAME = 'relaunch'
40
41export const USAGE = [
42 'Usage: /relaunch [now|compact|stay] [note...]',
43 ' /relaunch ask: relaunch now, compact first, or open the tab and stay',
44 ' /relaunch now [note] reopen this session in a new tab and exit this one',
45 ' /relaunch compact [note] compact first (the note also steers the summary), then relaunch',
46 ' /relaunch stay [note] open the new tab but leave this session running (it starts once you /exit)',
47 ' /relaunch <note> text without a keyword is a note; it still asks first',
48 'The note is put in the new session\'s prompt box. /relaunch help shows this.',
49].join('\n')
50
51/** The confirm dialog's labels and the mode each one picks. */
52const CHOICES: Record<string, Exclude<Mode, 'ask' | 'help'>> = {
53 'Relaunch now': 'now',
54 'Compact, then relaunch': 'compact',
55 'Open tab, stay here': 'stay',
56}
57
58const CANCEL = 'Cancel'
59
60/** How long a relaunch waits for this process to exit before giving up. */
61const WAIT_SECONDS = 120
62
63/**
64 * How long relaunch.ps1/relaunch.sh may take to open the tab: long enough to
65 * answer a macOS Automation prompt for Terminal.app or iTerm.
66 */
67const LAUNCH_TIMEOUT_MS = 120_000
68
69const ALREADY_WAITING = 'A tab from an earlier /relaunch is still waiting on this session. Type /exit here and it takes over.'
70
71/**
72 * `$.command.run` from inside a `command.run` hook is refused (it would wait
73 * on the turn the hook holds), so `/exit` runs from a timer just after
74 * /relaunch has answered.
75 */
76const EXIT_DELAY_MS = 300
77
78/**
79 * The welcome waits for the REPL to mount, or for a startup dialog such as a
80 * new .mcp.json server's approval: first try, retries (about a minute), spacing.
81 */
82const WELCOME_DELAY_MS = 1000
83const WELCOME_RETRIES = 120
84const WELCOME_RETRY_MS = 500
85
86/**
87 * Runs `/exit` once the current command has returned, saying so in the
88 * transcript if it is refused.
89 *
90 * @param $ the engine interface
91 * @param opened the line /relaunch answered with, repeated in the fallback
92 */
93function exitSoon($: EngineInterface, opened: string) {
94 $.clock.after(EXIT_DELAY_MS, () => {
95 $.command.run({ command: 'exit' }).catch((error: unknown) => {
96 const reason = error instanceof Error ? error.message : String(error)
97
98 $.ui.log(opened)
99 $.ui.log(`Could not exit this session automatically (${reason}); type /exit and the new tab takes over.`)
100 })
101 })
102}
103
104type Launched = { ok: true; opened: string } | { ok: false; text: string }
105
106/**
107 * Opens the new tab through relaunch.ps1 or relaunch.sh and leaves the welcome-back record.
108 *
109 * @param $ the engine interface
110 * @param stay whether the new tab waits for this session without a time limit
111 * @param note the note for the new session's prompt box, possibly empty
112 * @param startModel the model the session started on, so only a change is carried
113 */
114async function launch($: EngineInterface, stay: boolean, note: string, startModel: string | undefined): Promise<Launched> {
115 const sessionId = await $.session.id()
116 // The project root, not the current directory: a shell `cd` during the
117 // session moves the latter, and the new tab should open where it started.
118 const cwd = await $.session.root()
119 const model = await $.session.model()
120 const root = $.plugin.root
121 const wait = String(stay ? 0 : WAIT_SECONDS)
122 const windows = isWindowsPath(root)
123
124 const argv = windows
125 ? ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', `${root}\\hooks\\relaunch.ps1`, '-SessionId', sessionId, '-Cwd', cwd, '-WaitSeconds', wait]
126 : ['bash', `${root}/hooks/relaunch.sh`, '--session-id', sessionId, '--cwd', cwd, '--wait-seconds', wait]
127
128 if (isModelFlag(model) && model !== startModel) {
129 argv.push(windows ? '-Model' : '--model', model)
130 }
131
132 const run = await $.process.run(argv, { timeoutMs: LAUNCH_TIMEOUT_MS })
133 const result = parseLaunch(run.stdout, run.stderr)
134
135 if (!result.ok) {
136 return { ok: false, text: `/relaunch could not open the new tab: ${result.error}` }
137 }
138
139 const record: PendingRecord = {
140 id: sessionId,
141 at: await $.clock.now(),
142 note,
143 ttlMs: stay ? PENDING_STAY_TTL_MS : PENDING_TTL_MS,
144 waitMs: stay ? 0 : WAIT_SECONDS * 1000,
145 }
146
147 await $.store.set(`${PENDING_PREFIX}${sessionId}`, record)
148
149 const dropped = result.dropped_flags ? ' (its original flags could not be read, so it starts without them)' : ''
150
151 return { ok: true, opened: `Opened a new tab (${result.title}) running: ${commandLine(result.exe, result.args)}${dropped}` }
152}
153
154/**
155 * Whether a tab from an earlier /relaunch may still be waiting on this
156 * session; a second one would leave two tabs resuming the same id.
157 *
158 * @param $ the engine interface
159 */
160async function tabWaiting($: EngineInterface): Promise<boolean> {
161 const sessionId = await $.session.id()
162
163 return isWaiting(asPending(await $.store.get(`${PENDING_PREFIX}${sessionId}`)), await $.clock.now())
164}
165
166/**
167 * Compacts, then relaunches, once /relaunch has answered: the host refuses
168 * `$.session.compact` from inside a `command.run` hook (it would compact under
169 * the turn the hook holds). Progress goes to the transcript as `ui.log` lines,
170 * one call per line since a log line does not break on `\n`.
171 * The note doubles as the compaction instructions, like `/compact <text>`.
172 *
173 * @param $ the engine interface
174 * @param note the summary instructions and the new prompt box text, possibly empty
175 * @param startModel the model the session started on
176 */
177async function compactThenRelaunch($: EngineInterface, note: string, startModel: string | undefined) {
178 try {
179 const { skip } = await $.session.compact(note ? { instructions: note } : undefined)
180
181 if (skip) {
182 $.ui.log('Compaction was vetoed by a hook, so nothing was relaunched.')
183
184 return
185 }
186
187 const launched = await launch($, false, note, startModel)
188
189 if (!launched.ok) {
190 $.ui.log(launched.text)
191
192 return
193 }
194
195 $.ui.log(launched.opened)
196 $.ui.log('Exiting this session...')
197 exitSoon($, launched.opened)
198 } catch (error) {
199 const reason = error instanceof Error ? error.message : String(error)
200
201 $.ui.log(`/relaunch compact failed (${reason}). Run /compact, then /relaunch now.`)
202 }
203}
204
205/**
206 * Shows the welcome-back toast and puts the note in the prompt box, retrying
207 * while the REPL is still mounting or a dialog holds the keys.
208 *
209 * @param $ the engine interface
210 * @param note the note left by /relaunch, possibly empty
211 * @param attempt how many tries came before this one
212 */
213async function welcome($: EngineInterface, note: string, attempt: number) {
214 try {
215 const surfaces = await $.session.surfaces()
216 const filled = note && surfaces.includes('terminal') ? await $.prompt.fill({ text: note }) : null
217 const ready = surfaces.includes('terminal') && (!filled || filled.isFilled || !filled.refusal)
218
219 if (!ready && attempt < WELCOME_RETRIES) {
220 $.clock.after(WELCOME_RETRY_MS, () => void welcome($, note, attempt + 1))
221
222 return
223 }
224
225 if (!ready && note) {
226 $.ui.log(`Your /relaunch note (it could not go in the prompt box): ${note}`)
227 }
228
229 $.ui.toast('Resumed via /relaunch')
230 } catch (error) {
231 $.ui.log(`session-relaunch welcome failed: ${String(error)}`, { to: 'debug' })
232 }
233}
234
235/**
236 * Registers the command, the welcome back and the MCP-change nudge.
237 *
238 * @param on the engine's registrar
239 */
240export function register(on: On) {
241 // The model the session started on, so only a mid-session /model change
242 // is carried to the new process; its own flags already cover the rest.
243 let startModel: string | undefined
244
245 on('session.start', async ($, e, next) => {
246 await $.command.register({
247 name: COMMAND_NAME,
248 description: 'Restart this session in a new tab (--resume) so new MCP servers load',
249 argumentHint: '[now|compact|stay] [note]',
250 })
251
252 startModel = await $.session.model()
253
254 // A headless resume of the same id leaves the record for the real one.
255 if (!e.isInteractive) {
256 return next(e)
257 }
258
259 const id = await $.session.id()
260 const now = await $.clock.now()
261 let mine: PendingRecord | null = null
262
263 for (const key of await $.store.keys()) {
264 if (!key.startsWith(PENDING_PREFIX)) {
265 continue
266 }
267
268 const record = asPending(await $.store.get(key))
269 const isMine = record?.id === id && key === `${PENDING_PREFIX}${id}`
270
271 if (isMine && record && isFresh(record, now)) {
272 mine = record
273 }
274
275 if (isMine || !record || !isFresh(record, now)) {
276 await $.store.delete(key)
277 }
278 }
279
280 if (mine) {
281 const note = mine.note
282
283 $.clock.after(WELCOME_DELAY_MS, () => void welcome($, note, 0))
284 }
285
286 return next(e)
287 })
288
289 on('command.run', { command: COMMAND_NAME }, async ($, e, next) => {
290 const parsed = parseArgs(e.args)
291
292 if (parsed.mode === 'help') {
293 return { text: USAGE }
294 }
295
296 const surfaces = await $.session.surfaces()
297
298 if (!surfaces.includes('terminal')) {
299 return { text: '/relaunch needs a local terminal; this session has none.' }
300 }
301
302 let mode = parsed.mode
303
304 if (mode === 'ask') {
305 let answer: string
306
307 try {
308 answer = await $.ui.ask('Relaunch this session in a new tab?', {
309 header: 'Relaunch',
310 options: [...Object.keys(CHOICES), CANCEL],
311 })
312 } catch {
313 return { text: USAGE }
314 }
315
316 const picked = CHOICES[answer]
317
318 if (!picked) {
319 return { text: 'Relaunch cancelled.' }
320 }
321
322 mode = picked
323 }
324
325 if (await tabWaiting($)) {
326 return { text: ALREADY_WAITING }
327 }
328
329 if (mode === 'compact') {
330 $.clock.after(EXIT_DELAY_MS, () => void compactThenRelaunch($, parsed.note, startModel))
331
332 return { text: 'Compacting, then relaunching in a new tab...' }
333 }
334
335 const stay = mode === 'stay'
336 const launched = await launch($, stay, parsed.note, startModel)
337
338 if (!launched.ok) {
339 return { text: launched.text }
340 }
341
342 const opened = launched.opened
343
344 if (stay) {
345 return { text: `${opened}\nIt starts once you /exit here, however long that takes.` }
346 }
347
348 $.ui.toast('Relaunching in a new tab...')
349 exitSoon($, opened)
350
351 return { text: `${opened}\nExiting this session...` }
352 })
353 .catch(($, e, next) => (next.called ? next(e) : { text: `/relaunch failed: ${next.error.message}` }))
354
355 // MCP-change nudge: after the call, a transcript line for the person
356 // (`ui.log` is never sent to the model); the result goes back untouched.
357 on('tool.call', { tool: ['Bash', 'PowerShell', 'Write', 'Edit'] }, async ($, e, next) => {
358 const result = await next(e)
359
360 if (!result.deny && !result.isError) {
361 const change = mcpChange(e.tool, e)
362
363 if (change) {
364 $.ui.log(`MCP config changed (${change}); /relaunch to load it`)
365 }
366 }
367
368 return result
369 })
370 .catch(($, e, next) => next(e))
371}
372hooks/mcp-change.ts 43 lines1/**
2 * Spots a tool call that changed this machine's MCP configuration, so the
3 * mod can tell the person a /relaunch would load it. Pure: it reads the tool
4 * name and input and returns a short description, or null.
5 */
6
7/**
8 * `claude mcp <verb>` with a verb that changes the configured servers, the
9 * executable bare, as `claude.exe`/`claude.cmd`, or at the end of a path.
10 */
11const MCP_CLI_RE = /(?:^|[\s;&|(`'"\\/])claude(?:\.exe|\.cmd)?\s+mcp\s+(add-from-claude-desktop|add-json|add|remove)(?=\s|$|[;&|)`'"])/i
12
13/** The basename of a Windows or POSIX path. */
14function basename(path: string): string {
15 return path.split(/[\\/]/).at(-1) ?? path
16}
17
18/**
19 * Describes the MCP change a tool call made, or returns null.
20 *
21 * Covers `claude mcp add|add-json|add-from-claude-desktop|remove` in a Bash
22 * or PowerShell command, and a Write or Edit of a `.mcp.json`.
23 *
24 * @param tool the tool's name (`e.tool`)
25 * @param input the call's input (`e` itself works)
26 */
27export function mcpChange(tool: string, input: { command?: unknown; file_path?: unknown }): string | null {
28 if (tool === 'Bash' || tool === 'PowerShell') {
29 const command = typeof input.command === 'string' ? input.command : ''
30 const match = MCP_CLI_RE.exec(command)
31
32 return match ? `claude mcp ${match[1].toLowerCase()}` : null
33 }
34
35 if (tool === 'Write' || tool === 'Edit') {
36 const path = typeof input.file_path === 'string' ? input.file_path : ''
37
38 return basename(path).toLowerCase() === '.mcp.json' ? `${tool.toLowerCase()} of ${basename(path)}` : null
39 }
40
41 return null
42}
43hooks/parse.ts 147 lines1/**
2 * Pure helpers for /relaunch: the argument parser, the helper script's JSON
3 * line, the model check and the welcome-back record. No `$` here, so the
4 * tests import them directly.
5 */
6
7/** What the first word of `/relaunch ...` picks; `ask` when it is none. */
8export type Mode = 'ask' | 'now' | 'compact' | 'stay' | 'help'
9
10export type RelaunchArgs = { mode: Mode; note: string }
11
12const KEYWORDS: readonly Mode[] = ['now', 'compact', 'stay', 'help']
13
14/**
15 * Splits `/relaunch` arguments into an optional leading keyword and a note.
16 *
17 * Only the first word is a keyword (any case); everything after it is the
18 * note, verbatim. Text that does not start with a keyword is all note and
19 * leaves the mode at `ask`, so a typo like `/relaunch stya` still goes
20 * through the confirm dialog instead of relaunching on the spot.
21 *
22 * @param raw everything after `/relaunch`
23 */
24export function parseArgs(raw: string): RelaunchArgs {
25 const text = raw.trim()
26 const match = /^(\S+)\s*([\s\S]*)$/.exec(text)
27
28 if (!match) {
29 return { mode: 'ask', note: '' }
30 }
31
32 const first = match[1].toLowerCase()
33
34 if (first === '?' || first === '-h' || first === '--help') {
35 return { mode: 'help', note: '' }
36 }
37
38 if ((KEYWORDS as readonly string[]).includes(first)) {
39 return { mode: first as Mode, note: match[2].trim() }
40 }
41
42 return { mode: 'ask', note: text }
43}
44
45export type LaunchResult =
46 | { ok: true; pid: number; terminal: string; exe: string; args: string[]; title: string; dropped_flags?: boolean }
47 | { ok: false; error: string }
48
49/** The helper's one JSON line, or a failure naming what it printed instead. */
50export function parseLaunch(stdout: string, stderr: string): LaunchResult {
51 const line = stdout.trim().split(/\r?\n/).at(-1) ?? ''
52
53 try {
54 const parsed = JSON.parse(line) as Partial<LaunchResult> & { ok?: unknown }
55
56 if (parsed.ok === true || parsed.ok === false) {
57 return parsed as LaunchResult
58 }
59 } catch {
60 // Fall through to the failure below.
61 }
62
63 return { ok: false, error: (stderr || stdout || 'the relaunch helper printed nothing').trim() }
64}
65
66/**
67 * Whether the plugin lives at a Windows path (`C:\...`, `C:/...` or a UNC
68 * share), which picks relaunch.ps1 over relaunch.sh.
69 *
70 * @param path the plugin root
71 */
72export function isWindowsPath(path: string): boolean {
73 return /^[A-Za-z]:[\\/]|^\\\\/.test(path)
74}
75
76/** `exe args...` as one line, quoting the parts that hold spaces. */
77export function commandLine(exe: string, args: readonly string[]): string {
78 return [exe, ...args].map(part => (/\s/.test(part) || part === '' ? `"${part}"` : part)).join(' ')
79}
80
81/**
82 * Whether `$.session.model()`'s answer can go to `--model` as is.
83 *
84 * Probed on 2.1.288 it is the model id (`claude-opus-5-5`); an alias
85 * (`opus`), a provider id (`us.anthropic.claude-...`) or a `[1m]` suffix also
86 * pass. A display name (`Opus 5.5`, anything with spaces or brackets other
87 * than the suffix) does not, so the new session keeps its original flags.
88 *
89 * @param model what `$.session.model()` returned
90 */
91export function isModelFlag(model: string | undefined | null): model is string {
92 return typeof model === 'string' && /^[A-Za-z0-9][\w.:/@-]*(\[1m\])?$/.test(model) && model.length <= 200
93}
94
95/**
96 * What /relaunch leaves in the store for the session it reopens. `waitMs` is
97 * how long the new tab waits for this session to exit, 0 for no limit.
98 */
99export type PendingRecord = { id: string; at: number; note: string; ttlMs: number; waitMs: number }
100
101/** How long a record stays good after `/relaunch` and `/relaunch compact`. */
102export const PENDING_TTL_MS = 10 * 60 * 1000
103
104/** `/relaunch stay` waits for the person to /exit, which can take hours. */
105export const PENDING_STAY_TTL_MS = 12 * 60 * 60 * 1000
106
107/** The store key of one session's record. */
108export const PENDING_PREFIX = 'pending.'
109
110/** Narrows a store value to a record; anything else reads as none. */
111export function asPending(value: unknown): PendingRecord | null {
112 if (!value || typeof value !== 'object') {
113 return null
114 }
115
116 const record = value as Record<string, unknown>
117
118 if (typeof record.id !== 'string' || typeof record.at !== 'number') {
119 return null
120 }
121
122 return {
123 id: record.id,
124 at: record.at,
125 note: typeof record.note === 'string' ? record.note : '',
126 ttlMs: typeof record.ttlMs === 'number' ? record.ttlMs : PENDING_TTL_MS,
127 waitMs: typeof record.waitMs === 'number' ? record.waitMs : 0,
128 }
129}
130
131/** Whether a record is still within its time to live at `now`. */
132export function isFresh(record: PendingRecord, now: number): boolean {
133 return now >= record.at && now - record.at <= record.ttlMs
134}
135
136/**
137 * Whether the tab an earlier /relaunch opened may still be waiting on this
138 * session at `now`, so a second one would leave two tabs resuming one id.
139 */
140export function isWaiting(record: PendingRecord | null, now: number): boolean {
141 if (!record || !isFresh(record, now)) {
142 return false
143 }
144
145 return record.waitMs === 0 || now - record.at < record.waitMs
146}
147