A row above the prompt with the session folder, the git branch (switch it or copy it) and how far behind main you are

Small Claude Code mods by JashDev. Each one is a plugin with a hooks module, and works in the terminal and in the Code tab of Claude Desktop.
Add the marketplace once:
claude plugin marketplace add JashDev/claude-mods
Then install the mods you want:
claude plugin install session-path@jashdev-mods
New sessions pick it up. Sessions that were already open need a restart.
A row above the prompt with where the session is and which branch it is on.
📁 projects/acme/app 12 behind main ⎇ feat/login-form [▾] [Copy branch] [Copy path]
/Users/<you>/ (or /home/<you>/). Copy path copies the full path.git switch. It refuses, and tells you why, when there are uncommitted changes, while Claude is in the middle of a turn, or when git switch fails. A detached HEAD shows its short sha.origin/HEAD, else origin/main) your branch does not have yet. Hidden when you are up to date or on that branch.The row refreshes after every turn, after every Bash command and every 10 seconds, so a checkout made by Claude or in another terminal shows up on its own. On Claude's mobile app, which has no picker yet, the branch is shown as text.
Everything happens in the session folder, through git:
| When | Command |
|---|---|
| Every refresh | git rev-parse, git for-each-ref, git symbolic-ref, git rev-list --count (read only) |
| At session start and every 5 minutes | git fetch --quiet --no-tags origin <default branch>, with GIT_TERMINAL_PROMPT=0 so it never asks for credentials. It only updates that one remote-tracking ref |
| When you pick a branch | git status --porcelain --untracked-files=no, then git switch <branch> |
It never stashes, commits, pushes or deletes anything.
claude plugin validate plugins/session-path prints the same list: the events it hooks and every call it makes.
claude plugin test plugins/session-path
claude --plugin-dir plugins/session-path
MIT
hooks/register.tsx 190 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitInfo } from '../types'
5import { BRANCH_LIMIT, baseName, behindLabel, changedFiles, firstLine, parseBase, parseGit } from './git'
6
7const REFRESH_MS = 10_000
8// Keeps the remote's default branch current so the behind count means something.
9const FETCH_MS = 5 * 60_000
10const git = atom({ plugin: 'session-path', key: 'git' } as const, null)
11
12const REASON: Record<string, string> = {
13 'no-surface': 'no screen is attached',
14 'no-clipboard': 'this surface has no clipboard',
15 refused: 'the system refused it',
16}
17
18// `/Users/<name>/a/b` → `a/b`; home itself → `~`. The copy keeps the full path.
19export const shortPath = (path: string): string =>
20 path.replace(/^\/(Users|home)\/[^/]+(\/|$)/, (_, _root, slash) => (slash ? '' : '~'))
21
22// Work left running after a hook returns: a reload or unload may cut it off.
23const background = (work: Promise<unknown>) => void work.catch(() => undefined)
24
25const run = async ($: EngineInterface, argv: string[]) => $.process.run(['git', ...argv])
26
27const loadGit = async ($: EngineInterface): Promise<GitInfo | null> => {
28 try {
29 const head = await run($, ['rev-parse', '--abbrev-ref', 'HEAD'])
30 if (head.exitCode !== 0) return null
31 const sha = await run($, ['rev-parse', '--short', 'HEAD'])
32 const refs = await run($, [
33 'for-each-ref',
34 '--sort=-committerdate',
35 `--count=${BRANCH_LIMIT}`,
36 '--format=%(refname:short)',
37 'refs/heads',
38 ])
39
40 const symbolic = await run($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
41 const base = parseBase(symbolic.stdout, symbolic.exitCode)
42 const count = await run($, ['rev-list', '--count', `HEAD..${base}`])
43 const hasBase = count.exitCode === 0
44
45 return {
46 ...parseGit(head.stdout, sha.stdout, refs.stdout),
47 base: hasBase ? base : null,
48 behind: hasBase ? Number.parseInt(count.stdout.trim(), 10) || 0 : 0,
49 }
50 } catch {
51 return null
52 }
53}
54
55const refreshGit = async ($: EngineInterface) => {
56 const next = await loadGit($)
57 await update($, git, prev =>
58 prev &&
59 next &&
60 prev.branch === next.branch &&
61 prev.behind === next.behind &&
62 prev.base === next.base &&
63 prev.branches.join() === next.branches.join()
64 ? prev
65 : next,
66 )
67}
68
69// Updates only the remote's default branch; never asks for credentials.
70const fetchBase = async ($: EngineInterface) => {
71 const info = await read($, git)
72 if (!info?.base) return
73 try {
74 await $.process.run(['git', 'fetch', '--quiet', '--no-tags', 'origin', baseName(info.base)], {
75 env: { GIT_TERMINAL_PROMPT: '0' },
76 timeoutMs: 20_000,
77 })
78 } catch {
79 // Offline or no access: the count stays as of the last fetch.
80 }
81 await refreshGit($)
82}
83
84type Surface = Parameters<EngineInterface['ui']['copy']>[0]['surface']
85
86const copy = async ($: EngineInterface, text: string, what: string, surface: Surface) => {
87 const copied = await $.ui.copy({ text, surface })
88 $.ui.toast(copied.isCopied ? `${what} copied` : `Could not copy: ${REASON[copied.reason] ?? copied.reason}`)
89}
90
91const switchTo = async ($: EngineInterface, target: string, isWorking: boolean) => {
92 const current = (await read($, git)) ?? (await loadGit($))
93 if (!current || target === current.branch) return
94
95 if (isWorking) {
96 $.ui.toast('Claude is working: wait for the turn to finish before switching branches')
97 return
98 }
99
100 const status = await run($, ['status', '--porcelain', '--untracked-files=no'])
101 const changed = changedFiles(status.stdout)
102 if (changed > 0) {
103 $.ui.toast(
104 `${changed} uncommitted ${changed === 1 ? 'change' : 'changes'}. Commit or stash before switching branches.`,
105 )
106 return
107 }
108
109 const result = await run($, ['switch', target])
110 if (result.exitCode !== 0) {
111 $.ui.toast(`Could not switch to ${target}: ${firstLine(result.stderr) || 'git switch failed'}`)
112 return
113 }
114
115 $.ui.toast(`On ${target}`)
116}
117
118export const register: Register = on => {
119 on('session.start', async ($, e, next) => {
120 const started = await next(e)
121 background(refreshGit($).then(() => fetchBase($)))
122 $.clock.every(REFRESH_MS, () => background(refreshGit($)))
123 $.clock.every(FETCH_MS, () => background(fetchBase($)))
124
125 return started
126 })
127
128 on('turn.complete', async ($, e, next) => {
129 background(refreshGit($))
130 return next(e)
131 })
132
133 // A checkout run by Claude or by hand shows right away.
134 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
135 const ran = await next(e)
136 background(refreshGit($))
137 return ran
138 })
139
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 if (e.props.hasSurvey) return next(e)
142
143 const ui = $.ui.resolve(e)
144 const { Box, Text, Button } = ui
145 // Mobile draws no Select yet: there the branch is shown, not switched.
146 const Select = 'Select' in ui ? ui.Select : undefined
147 const cwd = await $.session.cwd()
148 // The first draw can come before session.start's read has landed; drawing
149 // is pure, so read git here without storing it.
150 const info = (await read($, git)) ?? (await loadGit($))
151
152 return (
153 <Box justifyContent="space-between" gap={1}>
154 <Box gap={1} flexShrink={1} minWidth={0}>
155 <Text dimColor>📁</Text>
156 <Text wrap="truncate-start">{shortPath(cwd)}</Text>
157 </Box>
158 <Box gap={1} flexShrink={0}>
159 {info && behindLabel(info) && <Text color="warning">{behindLabel(info)}</Text>}
160 {info && !Select && <Text dimColor>⎇ {info.branch}</Text>}
161 {info && Select && (
162 <Select
163 key="branch"
164 label="⎇"
165 value={info.branch}
166 options={(info.isDetached ? [info.branch, ...info.branches] : info.branches).map(name => ({
167 value: name,
168 label: info.isDetached && name === info.branch ? `${name} (detached)` : name,
169 }))}
170 onSelect={async (value: string) => {
171 await switchTo($, value, e.props.isWorking)
172 await refreshGit($)
173 $.ui.invalidate('ui.render')
174 }}
175 />
176 )}
177 {info && (
178 <Button
179 key="copy-branch"
180 label="Copy branch"
181 onPress={press => copy($, info.branch, 'Branch', press.surface)}
182 />
183 )}
184 <Button key="copy-path" label="Copy path" onPress={press => copy($, cwd, 'Path', press.surface)} />
185 </Box>
186 </Box>
187 )
188 })
189}
190hooks/git.ts 36 lines1import type { GitInfo } from '../types'
2
3export const BRANCH_LIMIT = 30
4
5// Output of `git rev-parse --abbrev-ref HEAD`, `git rev-parse --short HEAD`
6// and `git for-each-ref` → what the band draws.
7export const parseGit = (head: string, sha: string, refs: string): Omit<GitInfo, 'base' | 'behind'> => {
8 const name = head.trim()
9 const isDetached = name === 'HEAD'
10 const branch = isDetached ? sha.trim() : name
11 const listed = refs.split('\n').map(line => line.trim()).filter(Boolean)
12 const branches = isDetached || listed.includes(branch) ? listed : [branch, ...listed]
13
14 return { branch, isDetached, branches }
15}
16
17// Lines of `git status --porcelain --untracked-files=no`.
18export const changedFiles = (porcelain: string): number =>
19 porcelain.split('\n').filter(line => line.trim()).length
20
21export const firstLine = (text: string): string =>
22 text.split('\n').map(line => line.trim()).find(Boolean) ?? ''
23
24// `git symbolic-ref --short refs/remotes/origin/HEAD` → `origin/main`; a remote
25// without that ref falls back to `origin/main`.
26export const parseBase = (symbolic: string, exitCode: number): string =>
27 exitCode === 0 && symbolic.trim() ? symbolic.trim() : 'origin/main'
28
29// `origin/main` → `main`.
30export const baseName = (base: string): string => base.replace(/^[^/]+\//, '')
31
32export const behindLabel = (info: Pick<GitInfo, 'branch' | 'base' | 'behind'>): string | null =>
33 info.base && info.behind > 0 && info.branch !== baseName(info.base)
34 ? `${info.behind} behind ${baseName(info.base)}`
35 : null
36types/index.d.ts 18 lines1export type GitInfo = {
2 /** Current branch, or a short sha when detached. */
3 branch: string
4 isDetached: boolean
5 /** Local branches, most recently committed first. */
6 branches: string[]
7 /** The remote's default branch (`origin/main`), when it has one. */
8 base: string | null
9 /** Commits on `base` that HEAD does not have yet. */
10 behind: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'session-path': { git: GitInfo | null }
16 }
17}
18