Pin a session to its git worktree: every Bash call runs from the anchor, and the primary checkout is guarded against stray edits and git writes.

▄▀▀▄ ▄▀▄ █▄ █ ▄▀▀ █ █ ▄▀▄ █▀▄
▄▄▀▀▄▄ █▀█ █ ▀█ █ █▀█ █ █ █▀▄
██ ▀ ▀ ▀ ▀ ▀▀ ▀ ▀ ▀ ▀ ▀
█▄ ██ ▄█
▀▀▀██▀▀▀ PIN THE SESSION TO ITS WORKTREE

The problem: the shell leaves the worktree. In the study behind this library, 27k of 78.6k Bash calls started with cd, and 1,859 "shell cwd was reset" notices showed commands landing in the wrong checkout.
anchor pins a Claude Code session to the directory it started in. Every Bash call, including the calls of subagents that share the session's directory, runs from the anchor. When the anchor is a linked git worktree, the primary checkout is guarded: edits and git writes that would land there are denied.
/plugin marketplace add pourya7/claude-code-mods
/plugin install anchor@claude-code-mods
git rev-parse --git-common-dir). Git prints paths with symlinks resolved, so anchor spells them back the way the session's cwd spells them (for example /tmp/... rather than /private/tmp/... on macOS), and guards both spellings./anchor off stays off and /anchor <path> stays moved.cd '<anchor>' && { <command> plus a newline and }, unless it already starts with cd '<anchor>' && , so the rewrite happens once. The { } group makes the cd cover the whole command: without it, cd X && a & b would run b outside X, because & sends the whole && chain to the background. The newline before } keeps a trailing comment or heredoc from swallowing it. Single quotes in the path are escaped ('\'').cwd is anchored to that directory instead, with its own guard. A subagent spawned with isolation: "worktree" (or "remote"), and any subagent it spawns, passes through untouched, so its commits stay in its own worktree.protectPrimary is on:Edit, Write and NotebookEdit targets inside the primary checkout but outside the anchored worktree are denied. A worktree nested under the primary checkout (for example .worktrees/x) is never denied.git commit, checkout, switch, reset, stash, merge, rebase, push, branch -D or restore against the primary checkout, through cd <primary> or pushd <primary> (relative cd ../.. and cd -P included) or git -C <primary>, are denied. Leading NAME=value assignments and the command, env, exec, nohup and time wrappers are read through, as is /usr/bin/git. A cd before a lone & does not carry past it, matching the shell./anchor off). A toast shows each deny.╋ ANCHOR <worktree>@<branch>, cut to 40 columns. It clears while the anchor is off.The rewrite makes each command compound (cd … && cmd). Claude Code checks permissions on each part, so a narrow allow rule such as Bash(pwd) still has to match the command after the cd.
| Command | Does |
|---|---|
/anchor | Shows the anchor, the primary checkout and whether it is guarded |
/anchor <path> | Re-anchors to <path>, which may be relative to the current anchor. The path must be an existing directory. |
/anchor off | Disables anchor: no rewrite, no guard, no status line |
/anchor on | Restores the last anchor after /anchor off |
userConfig)| Option | Type | Default | Meaning |
|---|---|---|---|
protectPrimary | boolean | true | When anchored in a linked worktree, deny edits and git writes that land in the primary checkout. The Bash rewrite runs either way. |
Set it in /config, or in settings under pluginConfigs.anchor.options.protectPrimary.
/anchor reply. The sprite is drawn with half blocks in PICO-8 blue #29ADFF, with a white ring and a navy shadow. It turns grey while the anchor is off.
▄▀▀▄ ╋ ANCHOR SET fix-login@fix/login
▄▄▀▀▄▄ ANCHOR /work/repo/.worktrees/fix-login
██ PRIMARY /work/repo GUARDED
█▄ ██ ▄█ BASH cd '/work/repo/.worktrees/fix-login' && ...
▀▀▀██▀▀▀ OFF /anchor off
Status line:
╋ ANCHOR fix-login@fix/login
Toast on a deny:
╋ ANCHOR BLOCKED GIT COMMIT IN PRIMARY
╋ ANCHOR BLOCKED EDIT IN PRIMARY
Where nothing draws (claude -p, VS Code), /anchor replies with the same lines as plain text.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
| None | Yes: git rev-parse --path-format=absolute --git-dir --git-common-dir --show-toplevel, pwd -P and git branch --show-current, all read-only and run in the anchor directory. They run at session start (only when the session has no anchor yet), on /anchor <path>, and when a subagent is spawned with its own cwd. | Reads nothing. Stats the /anchor <path> target to check that it is a directory. Writes nothing. | No | No | None. Rewritten commands and deny reasons go only to your own Claude Code session. |
anchor changes tool calls: it rewrites Bash commands and denies some Edit, Write, NotebookEdit and Bash calls. It never approves a permission prompt.
. and .. are resolved, in two spellings: the session's and git's (symlinks resolved). Another symlink into the primary checkout, made inside the worktree for example, is not followed.cd, pushd and git -C in plain command chains. It does not read cd ~, cd -, popd, env -C, variables, eval, bash -c or other nested scripts. A cd inside ( … ) is treated as if it carried past the ), which can deny more than the shell would.claude plugin validate anchor
claude plugin test anchorhooks/register.tsx 265 lines1import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
2
3import type { AnchorPoint } from '../types'
4import {
5 ANCHOR_SPRITE,
6 BLUE,
7 GLYPH,
8 SPRITE_PALETTE,
9 SPRITE_PALETTE_OFF,
10 replyLines,
11 splitLine,
12 statusText,
13} from './describe'
14import { REAL_PWD, REV_PARSE, SHOW_BRANCH, readGitDirs } from './git'
15import { gitWriteTarget } from './guard'
16import { baseName, isInsideAny, resolvePath, respeller } from './paths'
17import { rewriteCommand } from './shell'
18import { spriteRows } from './sprite'
19
20type Engine = EngineInterface
21
22const current = { plugin: 'anchor', key: 'current' } as const
23const agents = { plugin: 'anchor', key: 'agents' } as const
24
25/** The output of `argv` run in `cwd`, or null when it fails. */
26const output = async ($: Engine, argv: readonly string[], cwd: string): Promise<string | null> => {
27 const run = await $.process.run([...argv], { cwd, timeoutMs: 10_000 }).catch(() => null)
28
29 return run?.exitCode === 0 ? run.stdout.trim() : null
30}
31
32/**
33 * Asks git where `path` sits; outside git (or with git failing) it anchors bare.
34 * Git prints paths with symlinks resolved, so they are spelled back the way
35 * `path` spells them (and the resolved spelling is kept beside, for the guard).
36 */
37const locate = async ($: Engine, path: string): Promise<AnchorPoint> => {
38 const bare: AnchorPoint = { isOn: true, path, root: path, primary: null, name: baseName(path), branch: null }
39 try {
40 const dirs = await $.process.run([...REV_PARSE], { cwd: path, timeoutMs: 10_000 })
41 if (dirs.exitCode !== 0) return bare
42 const real = readGitDirs(dirs.stdout, path)
43 const physical = await output($, REAL_PWD, path)
44 const respell = physical ? respeller(physical, path) : (dir: string) => dir
45 const root = respell(real.root)
46 const primary = real.primary ? respell(real.primary) : null
47 const branch = (await output($, SHOW_BRANCH, path)) || null
48 const point: AnchorPoint = { isOn: true, path, root, primary, name: baseName(root), branch }
49 if (real.root !== root) point.realRoot = real.root
50 if (real.primary !== primary) point.realPrimary = real.primary
51
52 return point
53 } catch {
54 return bare
55 }
56}
57
58/** Every spelling of the anchor's worktree and of its primary checkout. */
59const spellings = (point: AnchorPoint) => ({
60 roots: [point.root, point.realRoot].filter((dir): dir is string => Boolean(dir)),
61 primaries: [point.primary, point.realPrimary].filter((dir): dir is string => Boolean(dir)),
62})
63
64/** Shows the anchor on the status line, or clears it while off. */
65const showStatus = ($: Engine, point: AnchorPoint | null) =>
66 $.ui.status(point?.isOn ? statusText(point) : undefined)
67
68/** The anchor as it stands, when it is on. */
69const activeAnchor = async ($: Engine): Promise<AnchorPoint | null> => {
70 const { value } = await $.state.get(current)
71
72 return value?.isOn ? value : null
73}
74
75/** Records the anchor for the session and shows it on the status line. */
76const save = async ($: Engine, point: AnchorPoint) => {
77 await $.state.set(current, point)
78 showStatus($, point)
79}
80
81/**
82 * Agent tool calls that asked for an isolated worktree (or a remote run), by
83 * tool_use_id, from the call until its spawn. Short-lived, so a module value.
84 */
85const isolatedCalls = new Set<string>()
86
87/** Rewrites a Bash call into `point` and guards it, or guards an edit. */
88async function enforce(
89 $: Engine,
90 e: ToolCallInput,
91 next: (e: ToolCallInput) => Promise<ToolCallResult>,
92 point: AnchorPoint,
93 isGuarded: boolean,
94): Promise<ToolCallResult> {
95 const { roots, primaries } = spellings(point)
96 const isGuarding = isGuarded && primaries.length > 0
97
98 if (e.tool === 'Bash') {
99 const command = rewriteCommand(e.command, point.path)
100 const write = isGuarding ? gitWriteTarget(command, point.path, primaries, roots) : null
101 if (write) {
102 $.ui.toast(`${GLYPH} ANCHOR BLOCKED GIT ${write.verb.toUpperCase()} IN PRIMARY`)
103
104 return {
105 deny:
106 `anchor: \`git ${write.verb}\` would run in the primary checkout ${write.dir}, ` +
107 `outside the anchored worktree ${point.root}. Run it inside the anchor, ` +
108 `or have the user run /anchor off to lift the guard.`,
109 }
110 }
111
112 return next({ ...e, command })
113 }
114
115 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit') return next(e)
116 const target = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
117 const path = resolvePath(target, point.path)
118 if (isGuarding && isInsideAny(path, primaries) && !isInsideAny(path, roots)) {
119 $.ui.toast(`${GLYPH} ANCHOR BLOCKED ${e.tool.toUpperCase()} IN PRIMARY`)
120
121 return {
122 deny:
123 `anchor: ${path} is in the primary checkout ${point.primary}, outside the anchored ` +
124 `worktree ${point.root}. Edit the copy under ${point.root} instead, ` +
125 `or have the user run /anchor off to lift the guard.`,
126 }
127 }
128
129 return next(e)
130}
131
132export const register: Register = (on, options) => {
133 const isGuarded = options.protectPrimary !== false
134
135 const reply = (point: AnchorPoint | null) => ({ text: replyLines(point, isGuarded).join('\n') })
136
137 on('session.start', async ($, e, next) => {
138 await $.command
139 .register({
140 name: 'anchor',
141 description: 'Show the worktree anchor; /anchor <path> re-anchors, /anchor off disables',
142 argumentHint: '[path | off | on]',
143 })
144 .catch(() => $.ui.toast(`${GLYPH} ANCHOR: could not register /anchor`))
145 // session.start runs again on an enable, a worker respawn or a reload:
146 // keep the anchor the session already has (off, or moved by /anchor <path>).
147 const { value: kept } = await $.state.get(current)
148 if (kept) showStatus($, kept)
149 else await save($, await locate($, e.cwd))
150
151 return next(e)
152 })
153
154 on('command.run', { command: 'anchor' }, async ($, e) => {
155 const args = e.args.trim()
156 const { value: point = null } = await $.state.get(current)
157
158 if (args === '') return reply(point)
159
160 if (args === 'off') {
161 const off = point ? { ...point, isOn: false } : null
162 if (off) await save($, off)
163 else showStatus($, null)
164
165 return reply(off)
166 }
167
168 if (args === 'on' && point) {
169 await save($, { ...point, isOn: true })
170
171 return reply({ ...point, isOn: true })
172 }
173
174 const base = point?.path ?? (await $.session.cwd())
175 const target = resolvePath(args.replace(/^(['"])(.*)\1$/, '$2'), base)
176 const stat = await $.fs.stat(target).catch(() => null)
177 if (stat?.kind !== 'dir') return { text: `${GLYPH} ANCHOR: no such directory ${target}` }
178
179 const moved = await locate($, target)
180 await save($, moved)
181
182 return reply(moved)
183 })
184
185 on('tool.call', async ($, e, next) => {
186 if (e.tool === 'Agent') {
187 if (e.isolation && e.tool_use_id) isolatedCalls.add(e.tool_use_id)
188
189 return next(e)
190 }
191 if (e.tool !== 'Bash' && e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit') {
192 return next(e)
193 }
194 const point = await activeAnchor($)
195 if (!point) return next(e)
196 if (e.agentId === undefined) return enforce($, e, next, point, isGuarded)
197
198 // A subagent shares the session's anchor unless it was spawned with a
199 // directory of its own: its own cwd anchors it there, isolation frees it.
200 const { value: own } = await $.state.get({ ...agents, id: e.agentId })
201 if (!own) return enforce($, e, next, point, isGuarded)
202
203 return own.point ? enforce($, e, next, own.point, isGuarded) : next(e)
204 })
205
206 on('agent.spawn', async ($, e, next) => {
207 const isIsolated = isolatedCalls.delete(e.tool_use_id)
208 const spawned = await next(e)
209 if (!spawned.agentId) return spawned
210
211 if (isIsolated) {
212 await $.state.set({ ...agents, id: spawned.agentId }, { point: null })
213 } else if (e.cwd) {
214 const { value: anchor } = await $.state.get(current)
215 const point = await locate($, resolvePath(e.cwd, anchor?.path ?? (await $.session.cwd())))
216 await $.state.set({ ...agents, id: spawned.agentId }, { point })
217 } else if (e.parentAgentId !== undefined) {
218 // A subagent's own subagent starts where its parent runs.
219 const { value: parent } = await $.state.get({ ...agents, id: e.parentAgentId })
220 if (parent) await $.state.set({ ...agents, id: spawned.agentId }, parent)
221 }
222
223 return spawned
224 })
225
226 on('ui.render', { component: 'CommandOutput', props: { command: 'anchor' } }, ($, e, next) => {
227 if (e.props.isErrored) return next(e)
228 const { Box, Text } = $.ui.resolve(e)
229 const [title = '', ...rest] = e.props.text.split('\n')
230 const isOff = !title.includes('ANCHOR SET')
231 const sprite = spriteRows(ANCHOR_SPRITE, isOff ? SPRITE_PALETTE_OFF : SPRITE_PALETTE)
232
233 return (
234 <Box flexDirection="row" gap={2}>
235 <Box flexDirection="column" flexShrink={0}>
236 {sprite.map((runs, row) => (
237 <Box key={`sprite-${row}`} flexDirection="row">
238 {runs.map((run, index) => (
239 <Text color={run.color} backgroundColor={run.backgroundColor}>
240 {run.text}
241 </Text>
242 ))}
243 </Box>
244 ))}
245 </Box>
246 <Box flexDirection="column" flexShrink={1}>
247 <Text bold color={isOff ? '#83769C' : BLUE}>
248 {title}
249 </Text>
250 {rest.map((line, index) => {
251 const { label, value } = splitLine(line)
252
253 return (
254 <Box key={`line-${index}`} flexDirection="row" gap={1}>
255 <Text color={isOff ? '#83769C' : BLUE}>{label.padEnd(7)}</Text>
256 <Text wrap="truncate-middle">{value}</Text>
257 </Box>
258 )
259 })}
260 </Box>
261 </Box>
262 )
263 })
264}
265hooks/describe.ts 60 lines1import type { AnchorPoint } from '../types'
2import { shellQuote } from './shell'
3
4/** The status line glyph: single-width, unlike the anchor emoji. */
5export const GLYPH = '╋'
6
7/** Most columns the status line takes. */
8export const STATUS_COLUMNS = 40
9
10/** `╋ ANCHOR <worktree>@<branch>`, cut to fit the status line. */
11export const statusText = ({ name, branch }: Pick<AnchorPoint, 'name' | 'branch'>): string => {
12 const text = `${GLYPH} ANCHOR ${name}${branch ? `@${branch}` : ''}`
13
14 return text.length > STATUS_COLUMNS ? `${text.slice(0, STATUS_COLUMNS - 1)}…` : text
15}
16
17/** The anchor sprite: a 10x10 pixel grid, 5 terminal rows. */
18export const ANCHOR_SPRITE = [
19 '....ww....',
20 '...w..w...',
21 '....ww....',
22 '..bbbbbb..',
23 '....bb....',
24 '....bb....',
25 'b...bb...b',
26 'bb..bb..bb',
27 '.bbbbbbbb.',
28 '...nbbn...',
29] as const
30
31/** PICO-8 colours: white ring, blue body (anchor's signature), navy shade. */
32export const SPRITE_PALETTE = { w: '#FFF1E8', b: '#29ADFF', n: '#1D2B53' } as const
33
34/** The same sprite greyed out while the anchor is off. */
35export const SPRITE_PALETTE_OFF = { w: '#C2C3C7', b: '#5F574F', n: '#5F574F' } as const
36
37/** Signature colour for labels. */
38export const BLUE = '#29ADFF'
39
40/** The `/anchor` reply: a title line, then `LABEL value` lines. */
41export const replyLines = (point: AnchorPoint | null, isGuarded: boolean): string[] => {
42 if (!point) return [`${GLYPH} ANCHOR NONE`, 'SET /anchor <path>']
43 if (!point.isOn) {
44 return [`${GLYPH} ANCHOR OFF pass-through`, `LAST ${point.path}`, 'ON /anchor on or /anchor <path>']
45 }
46 const where = `${point.name}${point.branch ? `@${point.branch}` : ''}`
47 const lines = [`${GLYPH} ANCHOR SET ${where}`, `ANCHOR ${point.path}`]
48 if (point.primary) lines.push(`PRIMARY ${point.primary} ${isGuarded ? 'GUARDED' : 'OPEN'}`)
49 lines.push(`BASH cd ${shellQuote(point.path)} && ...`, 'OFF /anchor off')
50
51 return lines
52}
53
54/** Splits a reply line into its label and value at the first run of spaces. */
55export const splitLine = (line: string): { label: string; value: string } => {
56 const match = /^(\S+)\s+(.*)$/.exec(line)
57
58 return match ? { label: match[1] as string, value: match[2] as string } : { label: line, value: '' }
59}
60hooks/git.ts 38 lines1import { baseName, parentOf, resolvePath } from './paths'
2
3export type GitDirs = { root: string; primary: string | null }
4
5/** The argv anchor runs to learn where a directory sits in git. */
6export const REV_PARSE = [
7 'git',
8 'rev-parse',
9 '--path-format=absolute',
10 '--git-dir',
11 '--git-common-dir',
12 '--show-toplevel',
13] as const
14
15/** The argv for a directory's path with symlinks resolved, the spelling git prints. */
16export const REAL_PWD = ['pwd', '-P'] as const
17
18/** The argv for the checked-out branch (empty output when detached). */
19export const SHOW_BRANCH = ['git', 'branch', '--show-current'] as const
20
21/**
22 * Reads `git rev-parse --git-dir --git-common-dir --show-toplevel` output.
23 * A linked worktree has a git dir apart from the common one; the common
24 * dir's parent is the primary checkout (only when it is a `.git` folder:
25 * a bare repository has no checkout to protect).
26 */
27export const readGitDirs = (stdout: string, cwd: string): GitDirs => {
28 const [gitDir, commonDir, topLevel] = stdout.split('\n').map(line => line.trim())
29 if (!gitDir || !commonDir) return { root: cwd, primary: null }
30
31 const git = resolvePath(gitDir, cwd)
32 const common = resolvePath(commonDir, cwd)
33 const root = topLevel ? resolvePath(topLevel, cwd) : cwd
34 const isLinked = git !== common && baseName(common) === '.git'
35
36 return { root, primary: isLinked ? parentOf(common) : null }
37}
38hooks/guard.ts 173 lines1import { isInsideAny, resolvePath } from './paths'
2
3/** The git verbs that change a checkout, its refs or its remote. */
4export const WRITE_VERBS = [
5 'commit',
6 'checkout',
7 'switch',
8 'reset',
9 'stash',
10 'merge',
11 'rebase',
12 'push',
13 'restore',
14] as const
15
16export type GitWrite = { verb: string; dir: string }
17
18/** What ends a simple command: `&` backgrounds its whole list, the rest chain on. */
19type End = 'sequence' | 'and-or' | 'pipe' | 'background' | 'group'
20
21type Simple = { words: string[]; end: End }
22
23/** Splits a command into simple commands on ;, &&, ||, |, &, ( ) and newlines, outside quotes. */
24const splitCommands = (command: string): Simple[] => {
25 const commands: Simple[] = []
26 let words: string[] = []
27 let word = ''
28 let hasWord = false
29 let quote: string | null = null
30
31 const endWord = () => {
32 if (hasWord) words.push(word)
33 word = ''
34 hasWord = false
35 }
36 const endCommand = (end: End) => {
37 endWord()
38 if (words.length > 0) commands.push({ words, end })
39 else if (end === 'background' && commands.length > 0) (commands.at(-1) as Simple).end = end
40 words = []
41 }
42 const addChar = (char: string) => {
43 word += char
44 hasWord = true
45 }
46
47 for (let index = 0; index < command.length; index += 1) {
48 const char = command[index] as string
49 const nextChar = command[index + 1]
50 if (quote) {
51 if (char === quote) quote = null
52 else if (char === '\\' && quote === '"' && index + 1 < command.length) {
53 index += 1
54 word += command[index]
55 } else word += char
56 continue
57 }
58 if (char === "'" || char === '"') {
59 quote = char
60 hasWord = true
61 } else if (char === '\\' && index + 1 < command.length) {
62 index += 1
63 addChar(command[index] as string)
64 } else if (char === ' ' || char === '\t') endWord()
65 else if (char === '&') {
66 // `2>&1`, `>&2` and `&>file` are redirections, not separators.
67 if (word.endsWith('>') || word.endsWith('<') || nextChar === '>') addChar(char)
68 else if (nextChar === '&') {
69 index += 1
70 endCommand('and-or')
71 } else endCommand('background')
72 } else if (char === '|') {
73 if (nextChar === '|') {
74 index += 1
75 endCommand('and-or')
76 } else endCommand('pipe')
77 } else if (char === ';' || char === '\n') endCommand('sequence')
78 else if (char === '(' || char === ')') endCommand('group')
79 else addChar(char)
80 }
81 endCommand('sequence')
82
83 return commands
84}
85
86/** Shell words that run the command after them, in the same directory. */
87const PREFIX_WORDS = new Set(['{', '}', '!', 'time', 'nohup', 'command', 'builtin', 'exec', 'env'])
88
89/** Drops leading `NAME=value` assignments and wrappers (`command git`, `env X=1 git`). */
90const unwrap = (words: string[]): string[] => {
91 let index = 0
92 while (index < words.length) {
93 const word = words[index] as string
94 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || PREFIX_WORDS.has(word)) index += 1
95 else if (index > 0 && word.startsWith('-') && PREFIX_WORDS.has(words[index - 1] as string)) index += 1
96 else break
97 }
98
99 return words.slice(index)
100}
101
102/** `cd`/`pushd`'s directory, past `-L`/`-P`/`--`; null when it cannot be read (`~`, `-`, none). */
103const cdTarget = (words: string[]): string | null => {
104 let index = 1
105 while (index < words.length && /^-[LPe@]+$|^--$/.test(words[index] as string)) index += 1
106 const target = words[index]
107 if (!target || target.startsWith('~') || target.startsWith('-') || target.startsWith('+')) return null
108
109 return target
110}
111
112/** Git's global options that take a separate value. */
113const OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
114
115/** Reads one `git ...` simple command: the directory it acts on and its write verb, if any. */
116const readGit = (words: string[], cwd: string): GitWrite | null => {
117 let dir = cwd
118 let index = 1
119 while (index < words.length) {
120 const word = words[index] as string
121 if (!word.startsWith('-')) break
122 if (word === '-C') dir = resolvePath(words[index + 1] ?? '.', dir)
123 index += OPTIONS_WITH_VALUE.has(word) ? 2 : 1
124 }
125 const verb = words[index]
126 const rest = words.slice(index + 1)
127 if (verb === 'branch' && rest.includes('-D')) return { verb: 'branch -D', dir }
128 if ((WRITE_VERBS as readonly string[]).includes(verb ?? '')) return { verb: verb as string, dir }
129
130 return null
131}
132
133const asList = (paths: string | readonly string[]): readonly string[] =>
134 typeof paths === 'string' ? [paths] : paths
135
136/**
137 * The first git write in `command` that lands in the primary checkout but
138 * outside the anchored worktree, following `cd`, `pushd` and `git -C` from
139 * `cwd`; null when there is none. `primary` and `root` may each be given in
140 * several spellings (through a symlink and resolved).
141 *
142 * A list sent to the background with `&` runs in a subshell, so its `cd`
143 * does not carry past the `&`.
144 */
145export const gitWriteTarget = (
146 command: string,
147 cwd: string,
148 primary: string | readonly string[],
149 root: string | readonly string[],
150): GitWrite | null => {
151 const primaries = asList(primary)
152 const roots = asList(root)
153 let here = cwd
154 let listStart = cwd
155 for (const { words: raw, end } of splitCommands(command)) {
156 // A `{` group opens a list of its own: a `&` inside it backgrounds only its part.
157 if (raw[0] === '{') listStart = here
158 const words = unwrap(raw)
159 const [head] = words
160 if (head === 'cd' || head === 'pushd') {
161 const target = cdTarget(words)
162 if (target) here = resolvePath(target, here)
163 } else if (head === 'git' || head?.endsWith('/git')) {
164 const write = readGit(words, here)
165 if (write && isInsideAny(write.dir, primaries) && !isInsideAny(write.dir, roots)) return write
166 }
167 if (end === 'background') here = listStart
168 if (end === 'sequence' || end === 'background' || end === 'group') listStart = here
169 }
170
171 return null
172}
173hooks/paths.ts 64 lines1/** Collapses `.`, `..`, repeated and trailing slashes of an absolute path. */
2export const normalizePath = (path: string): string => {
3 const segments: string[] = []
4 for (const segment of path.split('/')) {
5 if (segment === '' || segment === '.') continue
6 if (segment === '..') segments.pop()
7 else segments.push(segment)
8 }
9
10 return `/${segments.join('/')}`
11}
12
13/** Resolves `path` against `base` when relative, then normalizes it. */
14export const resolvePath = (path: string, base: string): string =>
15 normalizePath(path.startsWith('/') ? path : `${base}/${path}`)
16
17/** True when `path` is `root` or lies beneath it (whole segments only). */
18export const isInside = (path: string, root: string): boolean => {
19 const target = normalizePath(path)
20 const top = normalizePath(root)
21
22 return target === top || top === '/' || target.startsWith(`${top}/`)
23}
24
25/** The directory holding `path`. */
26export const parentOf = (path: string): string => normalizePath(`${normalizePath(path)}/..`)
27
28/** The last segment of `path`. */
29export const baseName = (path: string): string => normalizePath(path).split('/').pop() || '/'
30
31/** True when `path` lies in any of `roots`. */
32export const isInsideAny = (path: string, roots: readonly string[]): boolean =>
33 roots.some(root => isInside(path, root))
34
35/**
36 * Maps paths from their resolved spelling (`real`, what git prints) back to
37 * the spelling the session uses (`logical`, its cwd through a symlink).
38 * The two share a tail of segments; the parts before it are the link and its
39 * target, and any path under the target is re-spelled under the link.
40 * A path outside the target comes back unchanged.
41 */
42export const respeller = (real: string, logical: string): ((path: string) => string) => {
43 const realParts = normalizePath(real).split('/').filter(Boolean)
44 const logicalParts = normalizePath(logical).split('/').filter(Boolean)
45 while (
46 realParts.length > 0 &&
47 logicalParts.length > 0 &&
48 realParts[realParts.length - 1] === logicalParts[logicalParts.length - 1]
49 ) {
50 realParts.pop()
51 logicalParts.pop()
52 }
53 const from = `/${realParts.join('/')}`
54 const to = `/${logicalParts.join('/')}`
55 if (from === to || from === '/') return path => normalizePath(path)
56
57 return path => {
58 const normal = normalizePath(path)
59 if (!isInside(normal, from)) return normal
60
61 return normalizePath(`${to}/${normal.slice(from.length)}`)
62 }
63}
64hooks/shell.ts 19 lines1/** Single-quotes a path for a POSIX shell; embedded quotes become '\''. */
2export const shellQuote = (path: string): string => `'${path.replace(/'/g, `'\\''`)}'`
3
4/** The exact prefix every anchored Bash command starts with. */
5export const anchorPrefix = (anchor: string): string => `cd ${shellQuote(anchor)} && `
6
7/**
8 * Prefixes `command` with a cd into the anchor, unless it already starts with
9 * that exact prefix. The command goes in a `{ ...\n}` group so the cd binds to
10 * all of it: `cd X && a & b` would run `b` outside X, since `&` ends the whole
11 * `&&` list. The newline before `}` keeps a trailing comment or heredoc from
12 * swallowing the brace.
13 */
14export const rewriteCommand = (command: string, anchor: string): string => {
15 const prefix = anchorPrefix(anchor)
16
17 return command.startsWith(prefix) ? command : `${prefix}{ ${command}\n}`
18}
19hooks/sprite.ts 38 lines1/** One run of same-styled cells in a sprite row. */
2export type SpriteRun = { text: string; color?: string; backgroundColor?: string }
3
4/**
5 * Turns a pixel grid (one character per pixel, `.` transparent, others keys
6 * of `palette`) into rows of half-block runs: one text row per two pixel rows,
7 * `▀` coloured by the top pixel over a background of the bottom one.
8 */
9export const spriteRows = (grid: readonly string[], palette: Readonly<Record<string, string>>): SpriteRun[][] => {
10 const rows: SpriteRun[][] = []
11 const width = Math.max(0, ...grid.map(line => line.length))
12
13 for (let y = 0; y < grid.length; y += 2) {
14 const runs: SpriteRun[] = []
15 for (let x = 0; x < width; x += 1) {
16 const top = palette[grid[y]?.[x] ?? '.']
17 const bottom = palette[grid[y + 1]?.[x] ?? '.']
18 const cell: SpriteRun =
19 top && bottom
20 ? top === bottom
21 ? { text: '█', color: top }
22 : { text: '▀', color: top, backgroundColor: bottom }
23 : top
24 ? { text: '▀', color: top }
25 : bottom
26 ? { text: '▄', color: bottom }
27 : { text: ' ' }
28 const last = runs[runs.length - 1]
29 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor) {
30 last.text += cell.text
31 } else runs.push(cell)
32 }
33 rows.push(runs)
34 }
35
36 return rows
37}
38types/index.d.ts 36 lines1/** Where the session is pinned, as anchor keeps it in $.state. */
2export type AnchorPoint = {
3 /** False after `/anchor off`: a pure pass-through until re-anchored. */
4 isOn: boolean
5 /** The directory every Bash call starts in. */
6 path: string
7 /** The top of the git worktree holding `path` (or `path` outside git), spelled like `path`. */
8 root: string
9 /** The primary checkout when `root` is a linked worktree, else null; spelled like `path`. */
10 primary: string | null
11 /** `root` as git prints it, symlinks resolved, when that differs. */
12 realRoot?: string
13 /** `primary` as git prints it, symlinks resolved, when that differs. */
14 realPrimary?: string | null
15 /** The worktree's folder name, for the status line. */
16 name: string
17 /** The checked-out branch, or null when detached or outside git. */
18 branch: string | null
19}
20
21/**
22 * A subagent that does not share the session's directory: one spawned with
23 * its own `cwd` gets its own anchor (`point`); one in an isolated worktree
24 * (or remote) gets `point: null`, and its calls pass through untouched.
25 */
26export type AgentAnchor = { point: AnchorPoint | null }
27
28declare module 'claude-code' {
29 interface PluginState {
30 anchor: {
31 current: AnchorPoint | null
32 agents: StateFamily<AgentAnchor>
33 }
34 }
35}
36