SLOPSHOPPER

ghostty-split

Opens the other side of /branch and /fork in a Ghostty split: the original on the left after /branch, the background session on the right after /fork.

newtoastprocess
★ 11v0.3.3MITupdated 2026-10-22jongwony/cc-plugin/ghostty-split
A shopper browsing a rack in a slop shop
README

ghostty-split

A Claude Code mod that opens the other side of /branch and /fork in a new Ghostty split beside the session that ran the command. The built-in commands run unchanged, and nothing extra is added to the conversation.

The original conversation always ends up on the left:

CommandNew paneThe pane you typed in
/branchon the left: claude --resume <original session id>, the conversation as it was before the branchon the right, moved onto the new branch (built-in behaviour)
/forkon the right: claude attach <id>, the new background session /fork startedon the left, still the original conversation

/branch returns focus to the pane you typed in when Ghostty is frontmost at the moment of the split. After /fork focus follows Ghostty's default for a new split.

Requirements

  • macOS.
  • Ghostty 1.3 or later with its AppleScript dictionary installed (new surface configuration, split, focus, and a terminal's pid), with AppleScript enabled (macos-applescript, on by default); with it off the mod cannot open panes.
  • Claude Code with function hooks (mods).
  • osascript on PATH, and claude on the PATH of the shell a new Ghostty pane starts.
  • That shell must not prompt at startup: the mod types the claude command into it.

Install

The marketplace's install-all script leaves this plugin out, since it drives a macOS-only facility. Install it deliberately:

claude plugin marketplace add jongwony/cc-plugin
claude plugin install ghostty-split@cc-plugin

Then /reload-plugins in a running session.

The first time the mod opens a split, macOS asks whether the terminal may control Ghostty (System Settings → Privacy & Security → Automation). Allow it; until then no split opens and a toast shows the osascript error.

Domain

A candidate session runs on macOS with TERM_PROGRAM=ghostty, or TERM=xterm-ghostty, while Ghostty is running. The second form admits a background session (one /fork or --bg started) whose Claude daemon was launched from Ghostty: the daemon's environment keeps TERM but not TERM_PROGRAM.

The domain is a candidate where one of two terminals is found, and the split is made from it:

  • Its own terminal: the Ghostty terminal whose reported pid is this Claude's. This rests on Claude being launched directly in the pane, not through a launcher that forks it, so it leads the pane's foreground process group.
  • The terminal viewing it: a background session has no terminal of its own; it runs under the daemon, and the pane showing it runs a claude client. When no terminal has this Claude's pid, the split is made from the focused terminal of Ghostty's front window, only while Ghostty is frontmost and that terminal's foreground process is claude. That is the pane the command was just typed in. Viewed from the mobile app, Ghostty is not frontmost, so nothing opens. The mod API names no attached client's pid or tty, so focus is the signal: a focus moved to another claude pane in the moment before the hook fires puts the split beside that pane.
  • A session that is not a candidate is untouched: both commands run as built.
  • Inside a candidate, any osascript failure, before or after membership is decided, shows as a toast: a denied Automation permission, a call that times out, or an older Ghostty without the AppleScript dictionary. Until Automation access to Ghostty is allowed, membership cannot be decided, so such a toast can appear in any candidate.

/fork detection relies on the format of /fork's confirmation line; a Claude Code that prints it differently opens no pane. The signal is that confirmation line exactly as /fork produced it: the mod reads the row before other plugins' hooks on the stored row may rewrite it, so a rewrite elsewhere does not change what the mod sees. It also presumes that no other command in the session prints a line in that format; if one does, a claude attach pane opens for it. After /cd or a worktree move, /branch's left pane can show Claude's own hint, cd … && claude --resume …; follow it there.

How it finds the session

  • /branch switches the running session to a new session id in place, so the mod reads the id before and after the command and resumes the original in the split.
  • /fork does not hand its new session id to the mod. Its confirmation line is the trigger instead: when the conversation keeps a line session running · <name> · <id> or session waiting for a prompt · <id> (optionally followed by a note such as edits this checkout), the right pane opens with claude attach <id>. Only /fork prints that line. If it never lands in the conversation, no pane opens and nothing is shown.
  • Both kinds of pane start in the session's project root, in Ghostty's own shell environment rather than the environment the session was launched with.

Known limits

  • New panes run claude from Ghostty's own shell PATH with default options; flags the original session was launched with are not reproduced.
  • Whether claude --resume <id> (the left pane after /branch) or claude --resume <id> --fork-session keeps old /fork confirmation rows again through the mod's hook was not verified; if it does, extra attach panes can open.
  • A session that depends on variables set when it was launched (a custom CLAUDE_CONFIG_DIR, provider or base-URL variables) is outside what the mod reproduces: claude --resume and claude attach in the new pane run without them.
  • If /fork prints no short id and the session name is itself 8 hex digits, that name is taken as the id. Claude Code's own reading of the line has the same ambiguity.
  • With agent view disabled (CLAUDE_CODE_DISABLE_AGENT_VIEW), /fork runs a subagent instead of a background session and prints no such line, so no pane opens.
Source 2 files
hooks/fork.ts 85 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { forkId } from './fork-id.ts'
3
4type Direction = 'left' | 'right'
5
6export const SPLIT = `on run argv
7  if application "Ghostty" is not running then return
8  set claudePid to (item 1 of argv) as integer
9  tell application "Ghostty"
10    try
11      set t to first terminal whose pid is claudePid
12    on error errorMessage number errorNumber
13      if errorNumber is not -1719 and errorNumber is not -1728 then error errorMessage number errorNumber
14      if not frontmost then return
15      set t to focused terminal of selected tab of front window
16      set viewerPid to (pid of t) as text
17      tell current application to set viewer to do shell script "ps -o comm= -p " & viewerPid & " | sed 's#.*/##'"
18      if viewer is not "claude" then return
19    end try
20    set cfg to new surface configuration
21    set initial working directory of cfg to item 2 of argv
22    set initial input of cfg to (item 3 of argv) & linefeed
23    if item 4 of argv is "left" then
24      split t direction left with configuration cfg
25    else
26      split t direction right with configuration cfg
27    end if
28    if item 5 of argv is "focus" and frontmost then focus t
29  end tell
30end run`
31
32const textOf = (content: readonly { type: string; text?: unknown }[]) =>
33  content.map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : '')).join('')
34
35// Runs SPLIT with the pid of this sh's parent (this Claude), or exits 0 silently without osascript.
36const SPAWN = 'command -v osascript >/dev/null || exit 0; exec osascript - "$PPID" "$@"'
37
38// Splits the Ghostty terminal whose pid is this Claude's and types `command` into the new pane; with `keepFocus` and
39// Ghostty frontmost, focus returns to that terminal. A background session has no terminal of its own: there the
40// terminal split is the focused one of Ghostty's front window, and only while Ghostty is frontmost and that terminal's
41// foreground process is a `claude` client. Nothing happens unless osascript exists, Ghostty is running and one of
42// those terminals is found; every other failure toasts. Never rejects.
43const split = async ($: EngineInterface, cwd: string, direction: Direction, command: string, keepFocus: boolean) => {
44  try {
45    const ran = await $.process.run(['/bin/sh', '-c', SPAWN, 'sh', cwd, command, direction, keepFocus ? 'focus' : ''], {
46      cwd: '/',
47      stdin: SPLIT,
48    })
49    if (ran.exitCode !== 0) $.ui.toast(ran.stderr.trim() || `osascript exited ${ran.exitCode}`)
50  } catch (err) {
51    try {
52      $.ui.toast(String(err))
53    } catch {}
54  }
55}
56
57// A session started in a Ghostty pane, or a background session whose daemon was: the daemon's environment keeps
58// TERM, never TERM_PROGRAM.
59const inGhostty = async ($: EngineInterface) =>
60  (await $.env.get('TERM_PROGRAM')) === 'ghostty' || (await $.env.get('TERM')) === 'xterm-ghostty'
61
62export const register: Register = on => {
63  // Stores every row unchanged. In a Ghostty session (inGhostty), once a main-loop local_command row in /fork's
64  // confirmation grammar is stored, starts a right split attached to the session it names.
65  on('session.append', async ($, e, next) => {
66    if (e.message.type !== 'system' || e.message.name !== 'local_command' || e.agentId) return next(e)
67    if (!(await inGhostty($))) return next(e)
68    const stored = await next(e)
69    const id = forkId(textOf(e.message.content))
70    if (id) void split($, await $.session.root(), 'right', `claude attach ${id}`, false)
71    return stored
72  }).catch(($, e, next) => next(e))
73
74  // In a Ghostty session, starts the left split with the original session, from the session root, once /branch has
75  // moved this process onto the branch.
76  on('command.run', { command: 'branch' }, async ($, e, next) => {
77    if (!(await inGhostty($))) return next(e)
78    const root = await $.session.root()
79    const before = await $.session.id()
80    const ran = await next(e)
81    if ((await $.session.id()) !== before) void split($, root, 'left', `claude --resume ${before}`, true)
82    return ran
83  }).catch(($, e, next) => next(e))
84}
85
hooks/fork-id.ts 12 lines
1const FORK_STATES = ['session running', 'session waiting for a prompt']
2
3// The id in /fork's confirmation, `<local-command-stdout><state> · [<name> · ]<8 hex>[ · <chip>]</local-command-stdout>`
4// on one line, or undefined.
5export const forkId = (text: string) => {
6  const body = text.match(/^<local-command-stdout>([^\n]*)<\/local-command-stdout>$/)?.[1]
7  if (body === undefined) return undefined
8  const segments = body.trim().split(' · ')
9  if (!FORK_STATES.includes(segments[0] ?? '')) return undefined
10  return segments.slice(1).filter(segment => /^[0-9a-f]{8}$/.test(segment)).pop()
11}
12