/terminal: show or open a Terminal-panel tab in this conversation's git worktree (Claude desktop app)

A Claude Code mod that adds /terminal: a terminal in the Claude desktop app's Terminal panel, beside the conversation, in the git worktree the conversation is working in.
/terminal show the tab already open in this conversation's worktree, or open one
/terminal -n open a new tab there, even if one is open (also --new)
/terminal <path> the same for another folder: a worktree's name, ~/..., absolute or relative
/terminal -n <path> a new tab in that folder
A tab counts as "in" a folder when its shell started there or anywhere inside it; the newest such tab is brought forward. Tabs started inside another worktree belong to that worktree, so the main checkout's terminal is never one that started in .claude/worktrees/....
With no path it uses this conversation's worktree, chosen in order:
git worktree add, EnterWorktree);Typing /terminal is the request, so the mod approves its own three calls (list the tabs, switch to one, open one); without that, auto mode's classifier has no request to judge them against and refuses them. The same tools called by Claude or another plugin are decided as usual, and the desktop app may still ask you to approve a folder. It needs the desktop app's Terminal panel: in a plain terminal session /terminal says it could not reach it.
Claude Code 2.1.287 or newer, and the Claude desktop app (for its Terminal panel). In a terminal:
claude plugin marketplace add cmorss/claude-mods
claude plugin install worktree-terminal@claude-mods
Then start a new session or type /reload-plugins. It is installed for you in every session and project. See the repository README for more.
hooks/register.ts is the command; hooks/terminal.ts holds the argument, path and tab rules; hooks/worktree.ts decides which worktree is the conversation's (the same file is in the worktree-files mod: keep the two alike). Run the tests with claude plugin test <this folder> and check it with claude plugin validate <this folder>.
hooks/register.ts 126 lines1import type { EngineInterface, Register } from 'claude-code';
2import { parseTabs, parseTerminalArgs, resolvePath, tabFor } from './terminal';
3import { baseName, conversationWorktree, parseWorktreeList } from './worktree';
4
5const COMMAND = 'terminal';
6const USAGE = '/terminal [-n|--new] [path]';
7const PLUGIN = 'worktree-terminal';
8
9/**
10 * The tool calls /terminal makes (read the tab list, switch to a tab, open one) are approved by
11 * this mod itself: the person asked for them by typing the command. Auto mode's classifier judges
12 * an action against the request behind it, and a command a mod runs has none, so without this it
13 * gives no verdict and the call is refused. Only this mod's own calls: the same tools called by
14 * the model or another plugin are decided as they always are.
15 */
16const ALLOW = {
17 decision: 'allow',
18 reason: 'Asked for with /terminal.',
19} as const;
20
21const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
22
23export const register: Register = (on) => {
24 on('session.start', async ($, e, next) => {
25 try {
26 await $.command.register({
27 name: COMMAND,
28 description: "Show this conversation's worktree terminal, or open one (-n: a new one)",
29 argumentHint: '[-n|--new] [path]',
30 });
31 } catch (error) {
32 $.ui.toast(`worktree-terminal: /${COMMAND} could not be registered: ${message(error)}`);
33 }
34 return next(e);
35 });
36
37 on('tool.check', { tool: 'mcp__terminal__list_terminal_tabs' }, async ($, e, next) =>
38 next.origin.plugin === PLUGIN ? ALLOW : next(e),
39 );
40 on('tool.check', { tool: 'mcp__ccd_view__show_pane' }, async ($, e, next) =>
41 next.origin.plugin === PLUGIN ? ALLOW : next(e),
42 );
43 on('tool.check', { tool: 'mcp__terminal__open_terminal_tab' }, async ($, e, next) =>
44 next.origin.plugin === PLUGIN ? ALLOW : next(e),
45 );
46
47 on('command.run', { command: COMMAND }, async ($, e) => {
48 try {
49 return await openTerminal($, e.args);
50 } catch (error) {
51 return { text: `Couldn't open a terminal: ${message(error)}` };
52 }
53 });
54};
55
56/**
57 * Bring forward the Terminal-panel tab already open in the directory, or open one there. The
58 * directory is the typed path, else this conversation's worktree, else the repository (or the
59 * session's directory outside one).
60 */
61async function openTerminal($: EngineInterface, args: string): Promise<{ text: string }> {
62 const asked = parseTerminalArgs(args);
63 if (asked.unknown) return { text: `Unknown option ${asked.unknown}. Use ${USAGE}.` };
64
65 const cwd = await $.session.cwd();
66 const git = (gitArgs: string[]) =>
67 $.process.run(['git', '--no-optional-locks', '-C', cwd, ...gitArgs], {
68 timeoutMs: 10_000,
69 });
70 const list = await git(['worktree', 'list', '--porcelain']);
71 const { main, linked } =
72 list.exitCode === 0 ? parseWorktreeList(list.stdout) : { main: undefined, linked: [] };
73
74 let dir: string;
75 let where: string;
76 if (asked.path) {
77 const home = (await $.process.run(['printenv', 'HOME'], { timeoutMs: 5_000 })).stdout.trim();
78 dir = resolvePath(asked.path, { cwd, home, linked });
79 where = dir;
80 } else {
81 const top = list.exitCode === 0 ? await git(['rev-parse', '--show-toplevel']) : undefined;
82 const cwdTop = top?.exitCode === 0 ? top.stdout.trim() : undefined;
83 const found = conversationWorktree({
84 cwdTop,
85 main,
86 linked,
87 messages: await $.session.messages(),
88 });
89 dir = found?.tree.path ?? cwdTop ?? cwd;
90 where = found ? `worktree ${found.tree.name} (${found.why})` : dir;
91 }
92
93 if (!asked.isNew) {
94 const listed = await $.tool.call({
95 tool: 'mcp__terminal__list_terminal_tabs',
96 });
97 if (listed.deny) return { text: `Couldn't read the terminal tabs: ${listed.deny}` };
98 const tab = tabFor(
99 parseTabs(listed.text),
100 dir,
101 linked.map((t) => t.path),
102 );
103 if (tab) {
104 const shown = await $.tool.call({
105 tool: 'mcp__ccd_view__show_pane',
106 pane: 'terminal',
107 tab_id: tab.tab_id,
108 });
109 if (shown.deny) return { text: `Couldn't show the terminal: ${shown.deny}` };
110 if (shown.isError) return { text: `Couldn't show the terminal: ${shown.text ?? ''}` };
111 return {
112 text: `Terminal for ${where} brought forward. /terminal -n opens another.`,
113 };
114 }
115 }
116
117 const opened = await $.tool.call({
118 tool: 'mcp__terminal__open_terminal_tab',
119 cwd: dir,
120 title: baseName(dir),
121 });
122 if (opened.deny) return { text: `Couldn't open a terminal: ${opened.deny}` };
123 if (opened.isError) return { text: `Couldn't open a terminal: ${opened.text ?? ''}` };
124 return { text: `New terminal in ${where}.` };
125}
126hooks/terminal.ts 95 lines1import type { Worktree } from './worktree';
2
3/** What `/terminal` was asked: a new tab or not, and a path if one was typed. */
4export function parseTerminalArgs(args: string): {
5 isNew: boolean;
6 path?: string;
7 unknown?: string;
8} {
9 let isNew = false;
10 const rest: string[] = [];
11 for (const word of args.trim().split(/\s+/).filter(Boolean)) {
12 if (word === '-n' || word === '--new') isNew = true;
13 else if (word.startsWith('-') && rest.length === 0) return { isNew, unknown: word };
14 else rest.push(word);
15 }
16 const path = rest.join(' ');
17 return path ? { isNew, path } : { isNew };
18}
19
20/** `/a/./b/../c/` → `/a/c`. */
21export function normalize(path: string): string {
22 const parts: string[] = [];
23 for (const part of path.split('/')) {
24 if (part === '' || part === '.') continue;
25 if (part === '..') parts.pop();
26 else parts.push(part);
27 }
28 return `/${parts.join('/')}`;
29}
30
31/**
32 * Where a typed path points: a worktree by its name, an absolute path, `~` and `~/…` under the
33 * home folder, anything else under the session's directory.
34 */
35export function resolvePath(
36 arg: string,
37 input: { cwd: string; home: string; linked: readonly Worktree[] },
38): string {
39 const named = input.linked.find((t) => t.name === arg);
40 if (named) return named.path;
41 if (arg.startsWith('/')) return normalize(arg);
42 if (arg === '~') return normalize(input.home);
43 if (arg.startsWith('~/')) return normalize(`${input.home}/${arg.slice(2)}`);
44 return normalize(`${input.cwd}/${arg}`);
45}
46
47/** A tab of the desktop app's Terminal panel, as `list_terminal_tabs` lists it. */
48export interface TerminalTab {
49 tab_id: string;
50 title: string | null;
51 /** Where its shell started (not where it has since `cd`'d to). */
52 cwd: string;
53}
54
55/** The tabs out of `list_terminal_tabs`'s text; none when it is not the list it should be. */
56export function parseTabs(text: string | undefined): TerminalTab[] {
57 try {
58 const value: unknown = JSON.parse(text ?? '');
59 if (!Array.isArray(value)) return [];
60 return value.filter(
61 (tab): tab is TerminalTab =>
62 typeof tab === 'object' &&
63 tab !== null &&
64 typeof (tab as TerminalTab).tab_id === 'string' &&
65 typeof (tab as TerminalTab).cwd === 'string',
66 );
67 } catch {
68 return [];
69 }
70}
71
72const isInside = (path: string, dir: string) => path === dir || path.startsWith(`${dir}/`);
73
74/**
75 * The tab to bring forward for `dir`: the newest one whose shell started there, else the newest
76 * one that started somewhere inside it. A tab inside another worktree that sits within `dir` (the
77 * main checkout holds `.claude/worktrees/…`) belongs to that worktree, not to `dir`.
78 */
79export function tabFor(
80 tabs: readonly TerminalTab[],
81 dir: string,
82 worktrees: readonly string[] = [],
83): TerminalTab | undefined {
84 const want = normalize(dir);
85 const exact = tabs.filter((tab) => normalize(tab.cwd) === want).at(-1);
86 if (exact) return exact;
87 const nested = worktrees.map(normalize).filter((w) => w !== want && isInside(w, want));
88 return tabs
89 .filter((tab) => {
90 const at = normalize(tab.cwd);
91 return isInside(at, want) && !nested.some((w) => isInside(at, w));
92 })
93 .at(-1);
94}
95hooks/worktree.ts 121 lines1import type { SessionMessage } from 'claude-code';
2
3/**
4 * Which git worktree belongs to this conversation. Pure: register.ts reads git and the transcript,
5 * then asks here. The same file is in the worktree-files and worktree-terminal mods; keep the
6 * two copies alike.
7 */
8
9/** One linked worktree of the repository. */
10export interface Worktree {
11 /** Absolute path. */
12 path: string;
13 /** Its folder's name (`home-screen`), what the person types after a command. */
14 name: string;
15}
16
17/** The main working tree and the linked worktrees, from `git worktree list --porcelain`. */
18export function parseWorktreeList(porcelain: string): {
19 main: string | undefined;
20 linked: Worktree[];
21} {
22 const paths = porcelain
23 .split('\n')
24 .filter((line) => line.startsWith('worktree '))
25 .map((line) => line.slice('worktree '.length).trim());
26 const [main, ...rest] = paths;
27 return { main, linked: rest.map((path) => ({ path, name: baseName(path) })) };
28}
29
30export const baseName = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path;
31
32const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
34/** Where in `text` a worktree is last named, by its absolute or repo-relative path; -1 when not. */
35function lastMention(text: string, tree: Worktree, main: string | undefined): number {
36 const forms = [tree.path];
37 if (main && tree.path.startsWith(`${main}/`)) forms.push(tree.path.slice(main.length + 1));
38 let at = -1;
39 for (const form of forms) {
40 // Not followed by more of a name: `worktrees/home` must not match inside `worktrees/home-screen`.
41 const re = new RegExp(`${escape(form)}(?![\\w.-])`, 'g');
42 for (let m = re.exec(text); m; m = re.exec(text)) at = Math.max(at, m.index);
43 }
44 return at;
45}
46
47/** The worktree named last in the newest text that names one (texts newest first). */
48export function newestNamed(
49 texts: readonly string[],
50 linked: readonly Worktree[],
51 main: string | undefined,
52): Worktree | undefined {
53 for (const text of texts) {
54 let best: { at: number; tree: Worktree } | undefined;
55 for (const tree of linked) {
56 const at = lastMention(text, tree, main);
57 if (at >= 0 && (!best || at > best.at)) best = { at, tree };
58 }
59 if (best) return best.tree;
60 }
61 return undefined;
62}
63
64const commandOf = (input: Record<string, unknown>): string =>
65 typeof input.command === 'string' ? input.command : '';
66
67/**
68 * What the conversation says about worktrees, newest first, in two tiers: `entered` holds what
69 * created or entered one (EnterWorktree, `git worktree add`), `mentioned` everything else that
70 * names paths. Output of `git worktree list` is left out: it names every worktree at once.
71 */
72export function worktreeTexts(messages: readonly SessionMessage[]): {
73 entered: string[];
74 mentioned: string[];
75} {
76 const entered: string[] = [];
77 const mentioned: string[] = [];
78 for (const message of messages) {
79 if (message.text) mentioned.push(message.text);
80 for (const use of message.toolUses) {
81 const command = commandOf(use.input);
82 if (use.tool === 'EnterWorktree') {
83 entered.push(`${JSON.stringify(use.input)} ${use.text ?? ''}`);
84 } else if (use.tool === 'Bash' && /\bworktree\s+add\b/.test(command)) {
85 entered.push(command);
86 }
87 if (use.tool === 'Bash' && /\bworktree\s+(list|prune)\b/.test(command)) continue;
88 mentioned.push(`${JSON.stringify(use.input)} ${use.text ?? ''}`);
89 }
90 }
91 return { entered: entered.reverse(), mentioned: mentioned.reverse() };
92}
93
94export interface Found {
95 tree: Worktree;
96 /** Why this one, for the command's line in the transcript. */
97 why: string;
98}
99
100/**
101 * This conversation's worktree: the session's own, else the newest one the conversation created
102 * or entered, else the newest one it worked in, else the repository's only one.
103 */
104export function conversationWorktree(input: {
105 cwdTop: string | undefined;
106 main: string | undefined;
107 linked: readonly Worktree[];
108 messages: readonly SessionMessage[];
109}): Found | undefined {
110 const { cwdTop, main, linked, messages } = input;
111 const own = linked.find((t) => t.path === cwdTop);
112 if (own) return { tree: own, why: 'the session runs in it' };
113 const { entered, mentioned } = worktreeTexts(messages);
114 const made = newestNamed(entered, linked, main);
115 if (made) return { tree: made, why: 'created or entered in this conversation' };
116 const used = newestNamed(mentioned, linked, main);
117 if (used) return { tree: used, why: 'the newest one this conversation worked in' };
118 if (linked.length === 1) return { tree: linked[0]!, why: "the repository's only worktree" };
119 return undefined;
120}
121