SLOPSHOPPER

anchor

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.

newrowsguardcommandtoaststatus
v0.1.0MITupdated 2026-10-03pourya7/claude-code-mods/anchor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · anchor
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /anchor ▄▀▀▄ ╋ ANCHOR SET app@feat/auth-refresh ▄▄▀▀▄▄ ANCHOR /work/app ██ BASH cd '/work/app' && ... █▄ ██ ▄█ OFF /anchor off ▀▀▀██▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ anchor: ╋ ANCHOR app@feat/auth-refresh

Draws

Command output
▄▀▀▄ ╋ ANCHOR SET app@feat/auth-refresh ▄▄▀▀▄▄ ANCHOR /work/app ██ BASH cd '/work/app' && ... █▄ ██ ▄█ OFF /anchor off ▀▀▀██▀▀▀
README

anchor

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

anchor pinning the session to its worktree and denying a git write in the primary checkout

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.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install anchor@claude-code-mods

What it does

  • Anchor. On session start, anchors to the session's cwd. If that is a linked git worktree, it also records the primary checkout (the parent of 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.
  • Kept across reloads. Session start fires again when the plugin is enabled, its worker respawns or its module reloads. The anchor the session already has is kept: /anchor off stays off and /anchor <path> stays moved.
  • Bash rewrite. Every Bash command becomes 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 ('\'').
  • Subagents. A subagent that shares the session's directory is rewritten and guarded like the session. A subagent spawned with its own 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.
  • Primary-checkout guard. Only when the anchor is a linked worktree and 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.
  • Bash commands that run 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.
  • The deny names the anchor and says how to lift the guard (/anchor off). A toast shows each deny.
  • Status line. ╋ 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.

Commands

CommandDoes
/anchorShows 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 offDisables anchor: no rewrite, no guard, no status line
/anchor onRestores the last anchor after /anchor off

Options (userConfig)

OptionTypeDefaultMeaning
protectPrimarybooleantrueWhen 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.

The UI

/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.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData leaving the machine
NoneYes: 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.NoNoNone. 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.

Limits

  • Paths are compared as written, after . 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.
  • The git-write check reads 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.
  • Subagents are told apart by the spawn anchor sees. A worktree-isolated subagent is not guarded at all, since anchor does not know where its worktree is. A subagent spawned before anchor loaded is treated as sharing the session's directory.
  • With a bare repository and its worktrees, there is no primary checkout to guard, so only the rewrite applies.

Develop

claude plugin validate anchor
claude plugin test anchor
Source 8 files
hooks/register.tsx 265 lines
1import 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}
265
hooks/describe.ts 60 lines
1import 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}
60
hooks/git.ts 38 lines
1import { 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}
38
hooks/guard.ts 173 lines
1import { 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}
173
hooks/paths.ts 64 lines
1/** 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}
64
hooks/shell.ts 19 lines
1/** 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}
19
hooks/sprite.ts 38 lines
1/** 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}
38
types/index.d.ts 36 lines
1/** 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