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.

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:
| Command | New pane | The pane you typed in |
|---|---|---|
/branch | on the left: claude --resume <original session id>, the conversation as it was before the branch | on the right, moved onto the new branch (built-in behaviour) |
/fork | on the right: claude attach <id>, the new background session /fork started | on 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.
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.osascript on PATH, and claude on the PATH of the shell a new Ghostty pane starts.claude command into it.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.
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:
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.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.
/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.claude from Ghostty's own shell PATH with default options; flags the original session was launched with are not reproduced.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.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./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.CLAUDE_CODE_DISABLE_AGENT_VIEW), /fork runs a subagent instead of a background session and prints no such line, so no pane opens.hooks/fork.ts 85 lines1import 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}
85hooks/fork-id.ts 12 lines1const 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