SLOPSHOPPER

session-relaunch

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

newguardcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-relaunch
› 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 › /relaunch ⎿ session-relaunch: Relaunch cancelled. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

session-relaunch

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

Install

/plugin marketplace add lperezmo/session-relaunch
/plugin install session-relaunch@session-relaunch

Usage

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

What it does on your machine

  • Commands it runs: /exit after opening the new tab (and /compact with compact).
  • Programs it starts: 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).
  • Hooks: the /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.
  • What it reads: the session id, starting folder, model, your note, tool calls that change MCP config, and the parent claude process's command line (to reuse its flags; they are shown in /relaunch's reply). No credentials.
  • What it stores: the session id and note, locally, until the new session reads them.
  • What it sends: nothing. No network calls.

License

MIT

Source 3 files
hooks/register.ts 372 lines
1/**
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}
372
hooks/mcp-change.ts 43 lines
1/**
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}
43
hooks/parse.ts 147 lines
1/**
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