Open a terminal in the session's worktree or any agent's worktree: /term, or the buttons above the prompt

A Claude Code mod that opens a terminal in the worktree you or your agents are working in.
When Claude runs agents in their own git worktrees (Agent with isolation: "worktree", EnterWorktree), their changes live in .claude/worktrees/<name>. /term opens a shell right there: as a split next to Claude, a tab, or a window.
| Command | Opens |
|---|---|
/term | the session's current directory (the worktree, once Claude entered one) |
/term list | lists every worktree of the repo, agents' marked |
/term <n> / /term main / /term <name> | a worktree by its number, the main one, or part of its folder or branch name |
… --split / --tab / --window | overrides the placement for this call |
While agent worktrees exist, a row above the prompt has one button per worktree.
/term runs at once, even mid-turn.
In /config, under worktree-terminal:
| Setting | Values |
|---|---|
| Terminal app | auto (default), tmux, zellij, kitty, wezterm, iterm, ghostty, warp, alacritty, gnome-terminal, konsole, terminal, system, custom |
| Placement | split (default, right of Claude), tab, window |
| Custom command | used with custom: argv split on spaces, {dir} replaced by the path, e.g. foot -D {dir} |
auto reads the environment of the window Claude runs in; a multiplexer (tmux, Zellij) wins over the terminal hosting it.
| Terminal | split | tab | window | Needs |
|---|---|---|---|---|
| tmux | ✓ | ✓ | → tab | |
| Zellij | ✓ | ✓ | → tab | |
| kitty | ✓ | ✓ | ✓ | allow_remote_control yes; listen_on unix:/tmp/kitty recommended; splits layout for a right split |
| WezTerm | ✓ | ✓ | ✓ | wezterm on PATH |
| iTerm2 | ✓ | ✓ | ✓ | Automation permission for iTerm2 (asked once) |
| Warp | → tab | ✓ | ✓ | |
| GNOME Terminal | → tab | ✓ | ✓ | |
| Konsole | → tab | ✓ | ✓ | |
| Ghostty | → window | → window | ✓ | |
| Alacritty | → window | → window | ✓ | |
| Terminal.app | → window | → window | ✓ | |
| anything else | → window | → window | ✓ | open -a Terminal (macOS), x-terminal-emulator (Linux) |
→ is the fallback used when the terminal has no way to open that placement from outside; /term says when it fell back.
claude plugin validate plugins/worktree-terminal
claude plugin test plugins/worktree-terminal
claude --plugin-dir plugins/worktree-terminal # load from the working copy, hot-reloaded
Adding a terminal: one entry in TERMINALS (hooks/terminals.ts) and one in CASES (tests/terminals.test.ts); a test fails while they disagree.
hooks/register.tsx 164 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Worktree } from '../types'
5import { findWorktree, label, parseEnv, parseWorktrees } from './lib'
6import { ENV_NAMES, customPlan, detectTerminal, findTerminal, planLaunch } from './terminals'
7import type { Placement, Plan } from './terminals'
8
9const worktrees = atom({ plugin: 'worktree-terminal', key: 'worktrees' } as const, [])
10const isHidden = atom({ plugin: 'worktree-terminal', key: 'isHidden' } as const, false)
11
12// Prints NAME=value for each name it is given, then the OS.
13const ENV_PROBE = 'for v in "$@"; do printf "%s=%s\\n" "$v" "$(printenv "$v")"; done; printf "OS=%s\\n" "$(uname)"'
14
15const PLACEMENTS: readonly Placement[] = ['split', 'tab', 'window']
16
17const HINTS: Record<string, string> = {
18 kitty: 'kitty needs `allow_remote_control yes` in kitty.conf; add `listen_on unix:/tmp/kitty` too, then restart kitty.',
19 wezterm: 'Is the `wezterm` CLI on PATH?',
20 iterm: 'Allow Claude Code to control iTerm2 under System Settings › Privacy & Security › Automation.',
21}
22
23type Config = { terminal: string; placement: Placement; customCommand: string }
24
25async function readEnv($: EngineInterface): Promise<Record<string, string>> {
26 const { stdout } = await $.process.run(['sh', '-c', ENV_PROBE, 'sh', ...ENV_NAMES])
27
28 return parseEnv(stdout)
29}
30
31async function refresh($: EngineInterface): Promise<Worktree[]> {
32 const cwd = await $.session.cwd()
33 const found = await $.process
34 .run(['git', 'worktree', 'list', '--porcelain'], { cwd })
35 .then(ran => (ran.exitCode === 0 ? parseWorktrees(ran.stdout) : []))
36 .catch(() => [])
37 await update($, worktrees, () => found)
38
39 return found
40}
41
42async function plan($: EngineInterface, config: Config, placement: Placement, dir: string): Promise<Plan | string> {
43 if (config.terminal === 'custom') {
44 return config.customCommand.trim() === ''
45 ? 'Terminal app is "custom" but Custom command is empty: set it in /config.'
46 : customPlan(config.customCommand, dir)
47 }
48 const env = await readEnv($)
49 const terminal = config.terminal === 'auto' ? detectTerminal(env) : findTerminal(config.terminal)
50
51 return terminal === undefined ? `Unknown terminal "${config.terminal}"` : planLaunch(terminal, placement, dir, env)
52}
53
54async function openTerminal($: EngineInterface, config: Config, dir: string, asked?: Placement): Promise<string> {
55 const placement = asked ?? config.placement
56 const planned = await plan($, config, placement, dir)
57 if (typeof planned === 'string') return planned
58
59 const { terminal } = planned
60 const ran = await $.process
61 .run(planned.argv, { cwd: dir, timeoutMs: 10_000 })
62 .catch((error: unknown) => ({ exitCode: 1, stderr: String(error) }))
63 if (ran.exitCode !== 0) {
64 const hint = HINTS[terminal.id]
65
66 return `Could not open ${terminal.name} in ${dir}: ${ran.stderr.trim() || `exit ${ran.exitCode}`}` +
67 (hint === undefined ? '' : `\n${hint}`)
68 }
69 const fellBack = planned.placement === placement ? '' : ` (${terminal.name} cannot open a ${placement} from outside)`
70
71 return `Opened a ${terminal.name} ${planned.placement} in ${dir}${fellBack}`
72}
73
74const listing = (list: readonly Worktree[], cwd: string): string =>
75 list
76 .map(
77 (one, i) =>
78 `${i + 1}. ${label(one)}${one.branch ? ` [${one.branch}]` : ''}${one.isAgent ? ' (agent)' : ''}` +
79 `${one.path === cwd ? ' ← current' : ''}\n ${one.path}`,
80 )
81 .join('\n')
82
83// `/term [target] [--split|--tab|--window]`
84const parseArgs = (args: string): { query: string; placement?: Placement } => {
85 const words = args.trim().split(/\s+/).filter(Boolean)
86 const placement = PLACEMENTS.find(one => words.includes(`--${one}`))
87
88 return { query: words.filter(word => !word.startsWith('--')).join(' '), placement }
89}
90
91export const register: Register = (on, options) => {
92 const placement = String(options.placement ?? 'split')
93 const config: Config = {
94 terminal: String(options.terminal ?? 'auto'),
95 placement: PLACEMENTS.find(one => one === placement) ?? 'split',
96 customCommand: String(options.customCommand ?? ''),
97 }
98
99 on('session.start', async ($, e, next) => {
100 await $.command.register({
101 name: 'term',
102 description: 'Open a terminal in the current worktree, or in another one (agents included)',
103 argumentHint: '[list | main | <n> | <name>] [--split | --tab | --window]',
104 immediate: true,
105 })
106 void refresh($)
107
108 return next(e)
109 })
110
111 on('command.run', { command: 'term' }, async ($, e) => {
112 const { query, placement: asked } = parseArgs(e.args)
113 const cwd = await $.session.cwd()
114 if (query === '') {
115 return { text: await openTerminal($, config, cwd, asked) }
116 }
117 const list = await refresh($)
118 if (query === 'list' || query === 'ls') {
119 return { text: list.length === 0 ? `No git worktrees under ${cwd}` : listing(list, cwd) }
120 }
121 const target = findWorktree(list, query)
122
123 return target === undefined
124 ? { text: `No worktree matches "${query}".\n${listing(list, cwd)}` }
125 : { text: await openTerminal($, config, target.path, asked) }
126 })
127
128 // Agents create and remove worktrees: keep the list fresh around them.
129 on('tool.call', { tool: ['Agent', 'EnterWorktree', 'ExitWorktree'] }, async ($, e, next) => {
130 const ran = await next(e)
131 void refresh($)
132
133 return ran
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 void refresh($)
138
139 return next(e)
140 })
141
142 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143 const agents = (await read($, worktrees)).filter(one => one.isAgent)
144 if (e.props.hasSurvey || agents.length === 0 || (await read($, isHidden))) {
145 return next(e)
146 }
147 const { Box, Button, Text } = $.ui.resolve(e)
148
149 return (
150 <Box gap={1}>
151 <Text dimColor>Agent worktrees:</Text>
152 {agents.slice(0, 6).map(one => (
153 <Button
154 key={`open:${one.path}`}
155 label={`▸ ${label(one)}`}
156 onPress={async () => $.ui.toast(await openTerminal($, config, one.path))}
157 />
158 ))}
159 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
160 </Box>
161 )
162 })
163}
164hooks/lib.ts 47 lines1import type { Worktree } from '../types'
2
3// Where Claude Code puts the worktrees of agents (Agent isolation: "worktree", EnterWorktree).
4const AGENT_WORKTREE = /\/\.claude\/worktrees\//
5
6export const parseWorktrees = (porcelain: string): Worktree[] =>
7 porcelain.split(/\n\n+/).flatMap((block): Worktree[] => {
8 const lines = block.split('\n')
9 const path = lines.find(line => line.startsWith('worktree '))?.slice(9)
10 const branch = lines.find(line => line.startsWith('branch '))?.slice(7).replace(/^refs\/heads\//, '')
11 if (path === undefined) return []
12
13 return [branch === undefined
14 ? { path, isAgent: AGENT_WORKTREE.test(path) }
15 : { path, branch, isAgent: AGENT_WORKTREE.test(path) }]
16 })
17
18export const label = (worktree: Worktree): string =>
19 worktree.path.split('/').filter(Boolean).pop() ?? worktree.path
20
21// `query` is a 1-based index, `main`, or part of a worktree's folder name or branch.
22export const findWorktree = (worktrees: readonly Worktree[], query: string): Worktree | undefined => {
23 const index = Number(query)
24 if (Number.isInteger(index) && index >= 1) {
25 return worktrees[index - 1]
26 }
27 if (query === 'main') {
28 return worktrees[0]
29 }
30 const needle = query.toLowerCase()
31
32 return (
33 worktrees.find(one => label(one).toLowerCase() === needle || one.branch?.toLowerCase() === needle) ??
34 worktrees.find(one => label(one).toLowerCase().includes(needle) || one.branch?.toLowerCase().includes(needle))
35 )
36}
37
38// `NAME=value` lines, as the env probe prints them.
39export const parseEnv = (text: string): Record<string, string> =>
40 Object.fromEntries(
41 text.split('\n').flatMap(line => {
42 const at = line.indexOf('=')
43
44 return at <= 0 ? [] : [[line.slice(0, at), line.slice(at + 1)]]
45 }),
46 )
47hooks/terminals.ts 197 lines1// Every terminal /term knows: how to recognise it from the environment of the
2// window Claude runs in, and the command that opens `dir` as a split, a tab or a window.
3
4export type Placement = 'split' | 'tab' | 'window'
5
6export type Env = Readonly<Record<string, string>>
7
8type Launch = (dir: string, env: Env) => string[]
9
10export type TerminalSpec = {
11 id: string
12 name: string
13 detect: (env: Env) => boolean
14 launch: Partial<Record<Placement, Launch>>
15}
16
17// The variables detection and the commands read; the module asks the host for exactly these.
18export const ENV_NAMES = [
19 'TMUX', 'ZELLIJ', 'TERM_PROGRAM', 'TERM', 'KITTY_WINDOW_ID', 'KITTY_PID', 'WEZTERM_PANE',
20 'ITERM_SESSION_ID', 'GHOSTTY_RESOURCES_DIR', 'ALACRITTY_WINDOW_ID', 'GNOME_TERMINAL_SCREEN',
21 'KONSOLE_VERSION',
22] as const
23
24const has = (env: Env, name: string): boolean => (env[name] ?? '') !== ''
25const isMac = (env: Env): boolean => env.OS === 'Darwin'
26
27// A launch whose terminal outlives the call and whose output never holds it open.
28export const detached = (argv: readonly string[]): string[] => [
29 'sh', '-c', 'nohup "$@" >/dev/null 2>&1 &', 'sh', ...argv,
30]
31
32// iTerm2 has no CLI for splits or tabs: AppleScript, the directory passed as an argument.
33const iterm = (open: string): Launch => dir => [
34 'osascript',
35 '-e', 'on run argv',
36 '-e', 'tell application "iTerm2"',
37 '-e', open,
38 '-e', 'tell current session of current window to write text "cd " & quoted form of (item 1 of argv) & " && clear"',
39 '-e', 'end tell',
40 '-e', 'end run',
41 dir,
42]
43
44export const TERMINALS: readonly TerminalSpec[] = [
45 // Multiplexers first: inside one, its panes beat the terminal hosting it.
46 {
47 id: 'tmux',
48 name: 'tmux',
49 detect: env => has(env, 'TMUX'),
50 launch: {
51 split: dir => ['tmux', 'split-window', '-h', '-c', dir],
52 tab: dir => ['tmux', 'new-window', '-c', dir],
53 },
54 },
55 {
56 id: 'zellij',
57 name: 'Zellij',
58 detect: env => has(env, 'ZELLIJ'),
59 launch: {
60 split: dir => ['zellij', 'action', 'new-pane', '--direction', 'right', '--cwd', dir],
61 tab: dir => ['zellij', 'action', 'new-tab', '--cwd', dir],
62 },
63 },
64 {
65 // Remote control: allow_remote_control in kitty.conf; listen_on makes it reliable.
66 // --no-response: without a socket the reply comes back on the tty Claude is reading.
67 id: 'kitty',
68 name: 'kitty',
69 detect: env => has(env, 'KITTY_WINDOW_ID') || has(env, 'KITTY_PID') || env.TERM === 'xterm-kitty',
70 launch: {
71 split: (dir, env) => [
72 'kitten', '@', 'launch', '--no-response', '--type=window', '--location=vsplit', '--cwd', dir,
73 ...(has(env, 'KITTY_WINDOW_ID') ? ['--next-to', `id:${env.KITTY_WINDOW_ID}`] : []),
74 ],
75 tab: dir => ['kitten', '@', 'launch', '--no-response', '--type=tab', '--cwd', dir],
76 window: dir => ['kitten', '@', 'launch', '--no-response', '--type=os-window', '--cwd', dir],
77 },
78 },
79 {
80 id: 'wezterm',
81 name: 'WezTerm',
82 detect: env => has(env, 'WEZTERM_PANE') || env.TERM_PROGRAM === 'WezTerm',
83 launch: {
84 split: (dir, env) => [
85 'wezterm', 'cli', 'split-pane', '--right', '--cwd', dir,
86 ...(has(env, 'WEZTERM_PANE') ? ['--pane-id', env.WEZTERM_PANE ?? ''] : []),
87 ],
88 tab: dir => ['wezterm', 'cli', 'spawn', '--cwd', dir],
89 window: dir => ['wezterm', 'cli', 'spawn', '--new-window', '--cwd', dir],
90 },
91 },
92 {
93 id: 'iterm',
94 name: 'iTerm2',
95 detect: env => env.TERM_PROGRAM === 'iTerm.app' || has(env, 'ITERM_SESSION_ID'),
96 launch: {
97 split: iterm('tell current session of current window to split vertically with default profile'),
98 tab: iterm('tell current window to create tab with default profile'),
99 window: dir => ['open', '-a', 'iTerm', dir],
100 },
101 },
102 {
103 id: 'ghostty',
104 name: 'Ghostty',
105 detect: env => env.TERM_PROGRAM === 'ghostty' || has(env, 'GHOSTTY_RESOURCES_DIR'),
106 launch: {
107 window: (dir, env) =>
108 isMac(env)
109 ? ['open', '-na', 'Ghostty', '--args', `--working-directory=${dir}`]
110 : detached(['ghostty', `--working-directory=${dir}`]),
111 },
112 },
113 {
114 id: 'warp',
115 name: 'Warp',
116 detect: env => env.TERM_PROGRAM === 'WarpTerminal',
117 launch: {
118 tab: dir => ['open', `warp://action/new_tab?path=${encodeURIComponent(dir)}`],
119 window: dir => ['open', `warp://action/new_window?path=${encodeURIComponent(dir)}`],
120 },
121 },
122 {
123 id: 'alacritty',
124 name: 'Alacritty',
125 detect: env => has(env, 'ALACRITTY_WINDOW_ID') || env.TERM === 'alacritty',
126 launch: {
127 window: (dir, env) =>
128 isMac(env)
129 ? ['open', '-na', 'Alacritty', '--args', '--working-directory', dir]
130 : detached(['alacritty', '--working-directory', dir]),
131 },
132 },
133 {
134 id: 'gnome-terminal',
135 name: 'GNOME Terminal',
136 detect: env => has(env, 'GNOME_TERMINAL_SCREEN'),
137 launch: {
138 tab: dir => ['gnome-terminal', '--tab', `--working-directory=${dir}`],
139 window: dir => ['gnome-terminal', '--window', `--working-directory=${dir}`],
140 },
141 },
142 {
143 id: 'konsole',
144 name: 'Konsole',
145 detect: env => has(env, 'KONSOLE_VERSION'),
146 launch: {
147 tab: dir => detached(['konsole', '--new-tab', '--workdir', dir]),
148 window: dir => detached(['konsole', '--workdir', dir]),
149 },
150 },
151 {
152 id: 'terminal',
153 name: 'Terminal.app',
154 detect: env => env.TERM_PROGRAM === 'Apple_Terminal',
155 launch: { window: dir => ['open', '-a', 'Terminal', dir] },
156 },
157 {
158 // Last: what an unrecognised terminal (or none: the desktop app) gets.
159 id: 'system',
160 name: 'the default terminal',
161 detect: () => true,
162 launch: {
163 window: (dir, env) => (isMac(env) ? ['open', '-a', 'Terminal', dir] : detached(['x-terminal-emulator'])),
164 },
165 },
166]
167
168export const TERMINAL_IDS = TERMINALS.map(spec => spec.id)
169
170export const findTerminal = (id: string): TerminalSpec | undefined => TERMINALS.find(spec => spec.id === id)
171
172export const detectTerminal = (env: Env): TerminalSpec =>
173 TERMINALS.find(spec => spec.detect(env)) ?? (TERMINALS[TERMINALS.length - 1] as TerminalSpec)
174
175const FALLBACK: Record<Placement, readonly Placement[]> = {
176 split: ['split', 'tab', 'window'],
177 tab: ['tab', 'split', 'window'],
178 window: ['window', 'tab', 'split'],
179}
180
181export type Plan = { terminal: TerminalSpec; placement: Placement; argv: string[] }
182
183// The asked placement, or the nearest one the terminal has.
184export const planLaunch = (terminal: TerminalSpec, placement: Placement, dir: string, env: Env): Plan => {
185 const chosen = FALLBACK[placement].find(one => terminal.launch[one] !== undefined) ?? 'window'
186 const launch = terminal.launch[chosen] ?? (() => [])
187
188 return { terminal, placement: chosen, argv: launch(dir, env) }
189}
190
191// A command of the person's own: argv split on spaces, {dir} the directory.
192export const customPlan = (template: string, dir: string): Plan => ({
193 terminal: { id: 'custom', name: 'custom command', detect: () => false, launch: {} },
194 placement: 'window',
195 argv: detached(template.trim().split(/\s+/).map(part => part.replaceAll('{dir}', dir))),
196})
197types/index.d.ts 8 lines1export type Worktree = { path: string; branch?: string; isAgent: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'worktree-terminal': { worktrees: Worktree[]; isHidden: boolean }
6 }
7}
8