SLOPSHOPPER

split

/split forks the current session into a terminal split beside it (cmux or tmux)

newcommandprocess
v0.1.0no licenseupdated 2026-10-04abdelrahman-elkady/claude-plugins/plugins/split
A shopper browsing a rack in a slop shop
README

split

/split forks the current Claude Code session into a new terminal split beside it. The main session keeps running; the fork starts with the full conversation so far.

/split                  fork into a split on the right
/split down             right | down | left | up
/split -n idea          name the fork yourself
/split --model sonnet   other claude flags pass through
  • Works in cmux and tmux. Anywhere else it prints the command to run.
  • The fork is named <name>-branch-<n>: the session's /rename name (or its generated title), counted per name.
  • Works mid-turn: the fork copies the transcript as saved at that moment.
  • Model and permission mode come from your settings, not the parent session. Pass --model / --permission-mode to override.

Built on Claude Code plugin function hooks (mods); tested on 2.1.289.

Source 1 files
hooks/register.ts 94 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Direction = 'right' | 'down' | 'left' | 'up'
4type Title = { customTitle?: string; aiTitle?: string }
5
6// Single-quotes a value for the shell the new split runs its command in.
7const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`
8
9// `/split [direction] [claude flags]`: the flags pass through to claude verbatim.
10const parse = (args: string): { direction: Direction; flags: string } => {
11  const trimmed = args.trim()
12  const match = /^(right|down|left|up)(?:\s+|$)/.exec(trimmed)
13  if (!match) return { direction: 'right', flags: trimmed }
14
15  return { direction: match[1] as Direction, flags: trimmed.slice(match[0].length) }
16}
17
18// The session's /rename name, else its generated title, read from its transcript;
19// mods have no title API. A fork copies these records, so -n must override them.
20const nameOf = async ($: EngineInterface, id: string) => {
21  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
22  const found = await $.process.run(['find', `${config}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`])
23  const transcript = found.stdout.split('\n').find(Boolean)
24  if (!transcript) return id.slice(0, 8)
25
26  const pattern = String.raw`"(customTitle|aiTitle)":"([^"\\]|\\.)*"`
27  const grep = await $.process.run(['grep', '-o', '-E', pattern, transcript])
28  const titles = grep.stdout
29    .split('\n')
30    .filter(Boolean)
31    .map(line => JSON.parse(`{${line}}`) as Title)
32
33  return (
34    titles.findLast(title => title.customTitle)?.customTitle ??
35    titles.findLast(title => title.aiTitle)?.aiTitle ??
36    id.slice(0, 8)
37  )
38}
39
40// `<name>-branch-<n>`, counted per parent name across sessions.
41const nextBranchName = async ($: EngineInterface, id: string) => {
42  const parent = await nameOf($, id)
43  const key = `branches:${parent}`
44  const n = Number((await $.store.get(key)) ?? 0) + 1
45  await $.store.set(key, n)
46
47  return `${parent}-branch-${n}`
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    await $.command.register({
53      name: 'split',
54      description: 'Fork this session into a split beside it, named <name>-branch-<n>',
55      argumentHint: '[right|down|left|up] [claude flags]',
56      immediate: true,
57    })
58
59    return next(e)
60  })
61
62  on('command.run', { command: 'split' }, async ($, e) => {
63    const { direction, flags } = parse(e.args)
64    const [id, cwd] = await Promise.all([$.session.id(), $.session.cwd()])
65    const hasName = /(?:^|\s)(?:-n|--name)(?:[\s=]|$)/.test(flags)
66    const name = hasName ? undefined : await nextBranchName($, id)
67    const claude = ['claude', '--resume', quote(id), '--fork-session', name && `--name ${quote(name)}`, flags]
68      .filter(Boolean)
69      .join(' ')
70    const shell = `cd ${quote(cwd)} && ${claude}`
71    const done = { text: `Branched into a ${direction} split${name ? ` as ${name}` : ''}.` }
72
73    // cmux splits the surface named by CMUX_SURFACE_ID, the one this session runs in.
74    if (await $.env.get('CMUX_SURFACE_ID')) {
75      const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH')) ?? 'cmux'
76      const run = await $.process.run([cmux, 'new-split', direction, '--command', shell, '--focus', 'true'])
77      if (run.exitCode === 0) return done
78
79      return { text: `cmux new-split failed: ${(run.stderr || run.stdout).trim()}\nRun it yourself: ${shell}` }
80    }
81
82    if (await $.env.get('TMUX')) {
83      const axis = direction === 'right' || direction === 'left' ? '-h' : '-v'
84      const before = direction === 'left' || direction === 'up' ? ['-b'] : []
85      const run = await $.process.run(['tmux', 'split-window', axis, ...before, '-c', cwd, shell])
86      if (run.exitCode === 0) return done
87
88      return { text: `tmux split-window failed: ${(run.stderr || run.stdout).trim()}\nRun it yourself: ${shell}` }
89    }
90
91    return { text: `Not in cmux or tmux. Run this in a new terminal:\n${shell}` }
92  })
93}
94