SLOPSHOPPER

loom-pane

A status line naming the session's loom role, plus guards that keep an owner inside its own worktree and ask before a removal loses work.

newguardstatuspromptprocess
★ 1v0.1.0BSD-3-Clauseupdated 2026-10-05mad01/thismoon/mods/loom-pane
A shopper browsing a rack in a slop shop
README

loom-pane

A Claude Code mod for sessions that follow the loom skill. It names the session's role in the status line under the prompt and turns the skill's rules into guards. The skill is unchanged; without the mod, loom works as before.

The name is historical. The mod once drew a pane of the repo's worktrees; the pane is gone, and the name stays because enabledPlugins on every machine keys on it.

Role and status line

At session.start (and again after a resume or /clear) the mod runs git rev-parse --show-toplevel and --git-common-dir; the canonical checkout is the parent of the common git dir. It then reads git worktree list --porcelain from the canonical checkout, once, for the paths the guards judge by, and reads it again when the session claims a worktree. Nothing runs on a timer. The role follows from there:

  • owner: the session's top level is ~/.worktrees/<repo>/<slug>, or a Bash command in the main loop enters one (cd ~/.worktrees/<repo>/<slug>, git worktree add ~/.worktrees/<repo>/<slug> ...). The skill's own flow claims from the canonical checkout and commits with cd <worktree> && git commit, so this is how the role usually turns up. The status line reads owner <slug>.
  • weaver: /loom weave was typed, run as the skill, or sent as a slash command, in any checkout of the repo. A weaver stays a weaver when it cds into a worktree to rebase. The line reads weaver · 3 worktrees, counting the linked worktrees git listed.
  • none: anything else; the line is cleared.

The engine prefixes a plugin's status line with its name, so on screen the line reads loom-pane: owner feat-pane; the text itself never repeats the name.

Guards

Active while guards is on (the default), and only for the main loop: a tool call with an agentId (a subagent, which owns its own isolation worktree) passes untouched.

  • Owner edits: for an owner, Write and Edit on a path under the canonical checkout or under any other worktree of the repo are denied. Other worktrees mean the ~/.worktrees/<repo>/* layout and nested ones such as .claude/worktrees/*. The reason reads loom-pane: <path> belongs to the canonical checkout; owners edit only their own worktree. The path is normalized first, so .. doesn't slip through. Its own worktree and paths outside the repo are allowed.
  • Owner shell: a Bash command is read for cd (a subshell's cd ends with it), git -C <path> and redirections. A mutating git verb acting in the canonical checkout or another worktree, or a > into one, is denied the same way. Mutating verbs: commit, add, checkout, switch, reset, rebase, merge, push, rm, mv, restore, stash (except stash list and stash show), cherry-pick, revert, clean, pull, apply, am. Path operands count for add, rm, mv, checkout, restore and clean only; -m and -F values never do. command git, time git and /usr/bin/git read as git.
  • Removals, any role: git worktree remove <path> asks only for a forced removal of a dirty tree, or a detached head with commits ahead of the default branch. Git itself refuses a dirty tree without --force, and a merged branch's tree is a clean teardown. git branch -D <name> asks only when the branch's commits exist nowhere else, or its worktree is dirty. Nowhere else means ahead of the default branch and not on origin/<name>, or with no upstream at all. The dialog reads <target> has 2 unmerged commits and uncommitted changes. Remove anyway? with Proceed and Cancel; Cancel denies. A dismissed dialog denies too in an interactive session; headless (-p) nobody can be asked and the call passes. A branch of some other repo (git -C /elsewhere branch -D x) isn't judged.

Weaver and none sessions get no edit guards.

How they fail open

A guard that can't be sure lets the call through. A command with a substitution, a heredoc, a variable in a path, bash -c, sudo, xargs, GIT_DIR= or GIT_WORK_TREE=, or an unbalanced quote isn't parsed and passes. A guard that throws or outruns its 10 s budget logs one debug line and passes. Writes the parser doesn't see: cp, mv, rm, tee, sed -i, python3 -c, and NotebookEdit. Path checks compare spellings, not inodes, so a symlinked spelling into the canonical checkout slips by. belt remains the fail-closed guard; this mod is a second opinion on top of it.

Config

FieldTypeDefaultMeaning
guardsbooleantruerun the three guards above

Dev loop

claude --plugin-dir mods/loom-pane         # from a checkout; a save hot-reloads
claude plugin validate mods/loom-pane      # what the engine would refuse
node --test mods/loom-pane/test/*.test.mjs # the parsers and the status text in lib/
claude plugin test mods/loom-pane          # the guards against the engine's test kit

The engine writes .claude-plugin/types/ beside the mod on load; npx -p typescript tsc -p mods/loom-pane type-checks against it.

Source 3 files
hooks/register.tsx 274 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LoomPaneRole } from '../types'
5import {
6  GIT_BUDGET_MS,
7  analyzeBash,
8  classifyPath,
9  denyReason,
10  detectLayout,
11  isWeaveArgs,
12  loomArgsOf,
13  ownerClaimFrom,
14  parseCount,
15  parseDefaultRef,
16  parseWorktreeList,
17  removalQuestion,
18  resolvePath,
19  shouldAskBranchDeletion,
20  shouldAskWorktreeRemoval,
21  statusText,
22  worktreeAt,
23  worktreeOfBranch,
24} from '../lib/loom'
25import type { Layout, Removal, WorktreeEntry } from '../lib/loom'
26
27const role = atom({ plugin: 'loom-pane', key: 'role' } as const, 'none' as LoomPaneRole)
28const canonical = atom({ plugin: 'loom-pane', key: 'canonical' } as const, null as string | null)
29const ownWorktree = atom({ plugin: 'loom-pane', key: 'ownWorktree' } as const, null as string | null)
30const worktreeRoot = atom({ plugin: 'loom-pane', key: 'worktreeRoot' } as const, null as string | null)
31const worktrees = atom({ plugin: 'loom-pane', key: 'worktrees' } as const, [] as string[])
32const repo = atom({ plugin: 'loom-pane', key: 'repo' } as const, null as string | null)
33const weaveSeen = atom({ plugin: 'loom-pane', key: 'weaveSeen' } as const, false)
34const isInteractive = atom({ plugin: 'loom-pane', key: 'isInteractive' } as const, false)
35
36type Logger = { ui: { log: (text: string, options: { to: 'debug' }) => void } }
37type Failed<E, R> = ((e: E) => R) & { event: string; error: { kind: string; message?: string } }
38
39// The one .catch every hook below carries: say why in the debug log, then
40// let the chain beneath answer as if the hook were absent. A guard that
41// throws or times out lets the call through; belt stays the fail-closed one.
42const skipOnFailure = <E, R>($: Logger, e: E, next: Failed<E, R>): R => {
43  const why = next.error.message === undefined ? next.error.kind : `${next.error.kind}: ${next.error.message}`
44  $.ui.log(`loom-pane: ${next.event} skipped (${why})`, { to: 'debug' })
45  return next(e)
46}
47
48// One git call under the budget: its stdout, or null when it failed, exited
49// non-zero or outran the budget (the run rejects then). Never throws.
50async function git($: EngineInterface, args: readonly string[], cwd: string): Promise<string | null> {
51  try {
52    const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: GIT_BUDGET_MS })
53    return ran.exitCode === 0 ? ran.stdout : null
54  } catch {
55    return null
56  }
57}
58
59async function layoutOf($: EngineInterface): Promise<Layout> {
60  return {
61    canonical: await read($, canonical),
62    ownWorktree: await read($, ownWorktree),
63    worktreeRoot: await read($, worktreeRoot),
64    worktrees: await read($, worktrees),
65  }
66}
67
68async function showStatus($: EngineInterface): Promise<void> {
69  $.ui.status(statusText(await read($, role), await layoutOf($)))
70}
71
72async function listWorktrees($: EngineInterface, canon: string): Promise<WorktreeEntry[] | null> {
73  const listed = await git($, ['worktree', 'list', '--porcelain'], canon)
74  return listed === null ? null : parseWorktreeList(listed).filter(entry => !entry.isBare)
75}
76
77// Re-reads the worktree list and stores the paths the guards judge by. Runs
78// when the role is detected and when the session claims a worktree, never
79// on a timer. A failed list keeps the paths already known.
80async function refreshWorktrees($: EngineInterface): Promise<void> {
81  const canon = await read($, canonical)
82  if (canon === null) {
83    await update($, worktrees, () => [])
84    return
85  }
86  const entries = await listWorktrees($, canon)
87  if (entries === null) {
88    $.ui.log('loom-pane: worktree list failed', { to: 'debug' })
89    return
90  }
91  await update($, worktrees, () => entries.map(entry => entry.path))
92}
93
94// Finds the repo around `cwd` and the session's role in its loom, writes
95// both to the state and pins the status line.
96async function detect($: EngineInterface, cwd: string): Promise<void> {
97  const toplevel = await git($, ['rev-parse', '--show-toplevel'], cwd)
98  let commonDir: string | null = null
99  if (toplevel !== null) {
100    // --path-format=absolute needs git 2.31; older gits answer the plain
101    // form, relative to cwd, which detectLayout resolves.
102    commonDir =
103      (await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)) ??
104      (await git($, ['rev-parse', '--git-common-dir'], cwd))
105  }
106  const home = await $.env.get('HOME')
107  const found = detectLayout({
108    cwd,
109    toplevel,
110    commonDir,
111    home: home ?? null,
112    weaveSeen: await read($, weaveSeen),
113  })
114  await update($, canonical, () => found.canonical)
115  await update($, ownWorktree, () => found.ownWorktree)
116  await update($, worktreeRoot, () => found.worktreeRoot)
117  await update($, repo, () => found.repo)
118  await update($, role, () => found.role)
119  await refreshWorktrees($)
120  await showStatus($)
121  $.ui.log(
122    found.canonical === null
123      ? `loom-pane: no git repo at ${cwd}`
124      : `loom-pane: repo ${found.repo} canonical ${found.canonical} role ${found.role}`,
125    { to: 'debug' },
126  )
127}
128
129// A Bash command that enters a worktree of the loom layout, by `cd` or by
130// `git worktree add`, makes this session its owner; a weaver stays a weaver.
131async function claimOwner($: EngineInterface, claims: readonly string[]): Promise<void> {
132  if (await read($, weaveSeen)) return
133  const claimed = ownerClaimFrom(claims, await read($, worktreeRoot))
134  if (claimed === null || claimed === (await read($, ownWorktree))) return
135  await update($, ownWorktree, () => claimed)
136  await update($, role, () => 'owner')
137  await refreshWorktrees($)
138  await showStatus($)
139  $.ui.log(`loom-pane: owner of ${claimed}`, { to: 'debug' })
140}
141
142async function markWeave($: EngineInterface, cwd: string): Promise<void> {
143  await update($, weaveSeen, () => true)
144  await detect($, cwd)
145}
146
147// Asks before a removal that would lose work. A dismissed dialog keeps the
148// target in an interactive session; headless (`-p`), nobody can be asked and
149// the removal goes ahead.
150async function askToRemove($: EngineInterface, label: string, ahead: number | null, isDirty: boolean): Promise<boolean> {
151  try {
152    const answer = await $.ui.ask(removalQuestion(label, ahead, isDirty), ['Proceed', 'Cancel'])
153    return answer === 'Proceed'
154  } catch (err) {
155    const interactive = await read($, isInteractive)
156    $.ui.log(`loom-pane: removal dialog closed (${err instanceof Error ? err.message : String(err)}); ${interactive ? 'kept' : 'headless, allowed'}`, { to: 'debug' })
157    return !interactive
158  }
159}
160
161// True when the removal may go ahead: nothing would be lost, or the person
162// chose Proceed. A git call that fails reads as nothing at risk.
163async function confirmRemoval(
164  $: EngineInterface,
165  removal: Removal,
166  entries: readonly WorktreeEntry[],
167  defaultRef: string,
168): Promise<boolean> {
169  if (removal.kind === 'worktree') {
170    const entry = worktreeAt(entries, removal.path)
171    const status = removal.isForce ? await git($, ['status', '--porcelain'], removal.path) : null
172    const isDirty = status !== null && status.trim() !== ''
173    const isDetached = entry?.isDetached === true
174    const ahead = isDetached ? parseCount(await git($, ['rev-list', '--count', `${defaultRef}..HEAD`], removal.path)) : null
175    if (!shouldAskWorktreeRemoval({ isForce: removal.isForce, isDirty, isDetached, ahead })) return true
176    return askToRemove($, removal.path, ahead, isDirty)
177  }
178  const linked = worktreeOfBranch(entries, removal.name)
179  const status = linked === null ? null : await git($, ['status', '--porcelain'], linked.path)
180  const isWorktreeDirty = status !== null && status.trim() !== ''
181  const ahead = parseCount(await git($, ['rev-list', '--count', `${defaultRef}..${removal.name}`], removal.location))
182  const remoteAhead = parseCount(
183    await git($, ['rev-list', '--count', `origin/${removal.name}..${removal.name}`], removal.location),
184  )
185  if (!shouldAskBranchDeletion({ ahead, remoteAhead, isWorktreeDirty })) return true
186  return askToRemove($, removal.name, ahead, isWorktreeDirty)
187}
188
189export const register: Register = (on, options) => {
190  const isGuarding = options.guards !== false
191
192  on('session.start', async ($, e, next) => {
193    const started = await next(e)
194    $.ui.log('loom-pane: loaded', { to: 'debug' })
195    await update($, isInteractive, () => e.isInteractive)
196    await detect($, e.cwd)
197    return started
198  }).catch(skipOnFailure)
199
200  on('classic.SessionStart', { source: ['resume', 'clear'] }, async ($, e, next) => {
201    await detect($, e.cwd)
202    return next(e)
203  }).catch(skipOnFailure)
204
205  // `/loom weave`, typed at the prompt or run as the skill, makes a session
206  // of this repo the weaver. The skill runs as it always did; this only
207  // watches.
208  on('prompt.submit', { text: /^\/loom\b/ }, async ($, e, next) => {
209    const args = loomArgsOf(e.text)
210    if (args !== null && isWeaveArgs(args)) await markWeave($, await $.session.cwd())
211    return next(e)
212  }).catch(skipOnFailure)
213
214  on('command.run', { command: 'loom' }, async ($, e, next) => {
215    if (isWeaveArgs(e.args)) await markWeave($, await $.session.cwd())
216    return next(e)
217  }).catch(skipOnFailure)
218
219  on('tool.call', { tool: 'Skill', skill: 'loom' }, async ($, e, next) => {
220    if (e.agentId === undefined && isWeaveArgs(e.args ?? '')) await markWeave($, await $.session.cwd())
221    return next(e)
222  }).catch(skipOnFailure)
223
224  // Owner guards, main loop only: an owner edits its own worktree and nothing
225  // else of the repo. Paths outside the repo are none of the loom's business,
226  // and a subagent in an isolation worktree is its own owner.
227  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
228    if (!isGuarding || e.agentId !== undefined || (await read($, role)) !== 'owner') return next(e)
229    const home = await $.env.get('HOME')
230    const path = resolvePath(e.file_path, await $.session.cwd(), home ?? null)
231    if (path === null) return next(e)
232    const cls = classifyPath(path, await layoutOf($))
233    if (cls === 'canonical' || cls === 'other') return { deny: denyReason(path, cls) }
234    return next(e)
235  }).catch(skipOnFailure)
236
237  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
238    if (e.agentId !== undefined) return next(e)
239    const home = await $.env.get('HOME')
240    const analysis = analyzeBash(e.command, { cwd: await $.session.cwd(), home: home ?? null })
241    if (analysis.isUncertain) return next(e)
242    await claimOwner($, analysis.claims)
243    if (!isGuarding || (await read($, role)) !== 'owner') return next(e)
244    const layout = await layoutOf($)
245    for (const path of analysis.writes) {
246      const cls = classifyPath(path, layout)
247      if (cls === 'canonical' || cls === 'other') return { deny: denyReason(path, cls) }
248    }
249    return next(e)
250  }).catch(skipOnFailure)
251
252  // Any role, main loop only: a force-delete or worktree removal that would
253  // lose commits or uncommitted changes asks first. Cancel denies the call.
254  on('tool.call', { tool: 'Bash', command: /\bgit\b[\s\S]*\b(branch|worktree)\b/ }, async ($, e, next) => {
255    if (!isGuarding || e.agentId !== undefined) return next(e)
256    const canon = await read($, canonical)
257    if (canon === null) return next(e)
258    const home = await $.env.get('HOME')
259    const analysis = analyzeBash(e.command, { cwd: await $.session.cwd(), home: home ?? null })
260    if (analysis.isUncertain || analysis.removals.length === 0) return next(e)
261    const layout = await layoutOf($)
262    const entries = (await listWorktrees($, canon)) ?? []
263    const defaultRef = parseDefaultRef(await git($, ['symbolic-ref', 'refs/remotes/origin/HEAD'], canon))
264    for (const removal of analysis.removals) {
265      // A branch of some other repo is not this loom's to judge.
266      if (removal.kind === 'branch' && classifyPath(removal.location, layout) === 'outside') continue
267      if (await confirmRemoval($, removal, entries, defaultRef)) continue
268      const label = removal.kind === 'branch' ? removal.name : removal.path
269      return { deny: `loom-pane: ${label} kept; the removal was not confirmed` }
270    }
271    return next(e)
272  }).catch(skipOnFailure)
273}
274
lib/loom.ts 716 lines
1// Pure helpers behind the loom-pane mod: no `$`, no I/O, so `node --test`
2// covers them without the engine. register.tsx is the only caller.
3
4export type Role = 'owner' | 'weaver' | 'none'
5
6/** One entry of `git worktree list --porcelain`. */
7export type WorktreeEntry = {
8  path: string
9  head: string | null
10  branch: string | null
11  isBare: boolean
12  isDetached: boolean
13}
14
15/** Where the repo's checkouts are, as far as the mod knows. */
16export type Layout = {
17  canonical: string | null
18  ownWorktree: string | null
19  worktreeRoot: string | null
20  /** Every worktree path git listed, nested ones under the canonical checkout included. */
21  worktrees: readonly string[]
22}
23
24export type PathClass = 'own' | 'canonical' | 'other' | 'outside'
25
26export type Detected = Omit<Layout, 'worktrees'> & { repo: string | null; role: Role }
27
28/** How long one git call may take. */
29export const GIT_BUDGET_MS = 2_000
30
31/** The ref the ahead count compares against when origin names no HEAD. */
32export const DEFAULT_BRANCH = 'main'
33
34// The git verbs that change a working tree, its index or its refs. The loom
35// skill's list, plus the verbs that do the same under another name.
36const MUTATING_GIT_VERBS = new Set([
37  'commit',
38  'add',
39  'checkout',
40  'switch',
41  'reset',
42  'rebase',
43  'merge',
44  'push',
45  'rm',
46  'mv',
47  'restore',
48  'stash',
49  'cherry-pick',
50  'revert',
51  'clean',
52  'pull',
53  'apply',
54  'am',
55])
56
57// The verbs whose path operands name what they change; the others take
58// messages, refs and patch files, whose spelling says nothing about writes.
59const OPERAND_VERBS = new Set(['add', 'rm', 'mv', 'checkout', 'restore', 'clean'])
60
61// Flags that take the next word as a value, so that word is not a path.
62const VALUE_FLAGS = new Set(['-m', '-F', '--message', '--file', '-b', '-B', '--reason', '-C'])
63
64// Commands that run another command the tokenizer cannot see into.
65const OPAQUE_COMMANDS = new Set(['sh', 'bash', 'zsh', 'eval', 'exec', 'xargs', 'sudo', 'env', 'popd'])
66
67// Prefixes that run the command after them unchanged.
68const PASSTHROUGH_COMMANDS = new Set(['command', 'time'])
69
70const CONTROL_OPS = new Set(['&&', '||', ';', ';;', '|', '|&', '&', '\n'])
71
72const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/
73
74const GIT_ENV = new Set(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE'])
75
76function trimSlash(path: string): string {
77  const trimmed = path.replace(/\/+$/, '')
78  return trimmed === '' ? '/' : trimmed
79}
80
81/** Collapses `.`, `..` and repeated slashes in an absolute path. */
82export function normalizePath(path: string): string {
83  const parts: string[] = []
84  for (const part of path.split('/')) {
85    if (part === '' || part === '.') continue
86    if (part === '..') {
87      parts.pop()
88      continue
89    }
90    parts.push(part)
91  }
92  return `/${parts.join('/')}`
93}
94
95export function baseName(path: string): string {
96  const parts = trimSlash(path).split('/')
97  return parts[parts.length - 1] ?? ''
98}
99
100export function dirName(path: string): string {
101  const trimmed = trimSlash(path)
102  const cut = trimmed.lastIndexOf('/')
103  return cut <= 0 ? '/' : trimmed.slice(0, cut)
104}
105
106function isSamePath(a: string | null, b: string | null): boolean {
107  return a !== null && b !== null && trimSlash(a) === trimSlash(b)
108}
109
110/** True when `path` is `root` or lies below it, by spelling (not by inode). */
111export function isUnder(path: string, root: string): boolean {
112  const p = trimSlash(path)
113  const r = trimSlash(root)
114  return p === r || p.startsWith(r === '/' ? '/' : `${r}/`)
115}
116
117/**
118 * The absolute path a shell word names, or null when it cannot be known
119 * without running the shell: a variable or substitution, `~user`, `-`, or
120 * `~` with no home.
121 */
122export function resolvePath(word: string, cwd: string, home: string | null): string | null {
123  if (word === '' || word === '-' || /[$`]/.test(word)) return null
124  if (word.startsWith('/')) return normalizePath(word)
125  if (word === '~' || word.startsWith('~/')) {
126    return home === null ? null : normalizePath(`${home}${word.slice(1)}`)
127  }
128  if (word.startsWith('~')) return null
129  return normalizePath(`${cwd}/${word}`)
130}
131
132/**
133 * Which checkout a path falls in: the longest root that holds it wins, so a
134 * worktree nested under the canonical checkout counts as a worktree and the
135 * own worktree wins over the `~/.worktrees/<repo>` root above it.
136 */
137export function classifyPath(path: string, layout: Layout): PathClass {
138  const roots: Array<{ root: string; cls: PathClass }> = []
139  if (layout.ownWorktree !== null) roots.push({ root: layout.ownWorktree, cls: 'own' })
140  if (layout.canonical !== null) roots.push({ root: layout.canonical, cls: 'canonical' })
141  if (layout.worktreeRoot !== null) roots.push({ root: layout.worktreeRoot, cls: 'other' })
142  for (const worktree of layout.worktrees) {
143    const cls: PathClass = isSamePath(worktree, layout.ownWorktree)
144      ? 'own'
145      : isSamePath(worktree, layout.canonical)
146        ? 'canonical'
147        : 'other'
148    roots.push({ root: worktree, cls })
149  }
150  let best: { root: string; cls: PathClass } | null = null
151  for (const candidate of roots) {
152    if (!isUnder(path, candidate.root)) continue
153    if (best === null || trimSlash(candidate.root).length > trimSlash(best.root).length) best = candidate
154  }
155  return best === null ? 'outside' : best.cls
156}
157
158export function denyReason(path: string, cls: 'canonical' | 'other'): string {
159  const holder = cls === 'canonical' ? 'the canonical checkout' : 'another worktree'
160  return `loom-pane: ${path} belongs to ${holder}; owners edit only their own worktree`
161}
162
163/**
164 * The canonical checkout behind `git rev-parse --git-common-dir`: the parent
165 * of the common git dir, which git prints relative to `cwd` from the main
166 * worktree and absolute from a linked one.
167 */
168export function canonicalFrom(commonDir: string | null, cwd: string): string | null {
169  const trimmed = commonDir?.trim() ?? ''
170  if (trimmed === '') return null
171  const absolute = trimmed.startsWith('/') ? normalizePath(trimmed) : normalizePath(`${cwd}/${trimmed}`)
172  return dirName(absolute)
173}
174
175/** `~/.worktrees/<repo>`, the loom's layout for one repo. */
176export function worktreeRootFor(home: string | null, repo: string): string | null {
177  return home === null || home === '' ? null : `${trimSlash(home)}/.worktrees/${repo}`
178}
179
180/**
181 * The worktree a path claims in the loom layout: `~/.worktrees/<repo>/<slug>`
182 * for a path at or below it, null for anything else.
183 */
184export function worktreeClaimedBy(path: string, worktreeRoot: string | null): string | null {
185  if (worktreeRoot === null) return null
186  const root = trimSlash(worktreeRoot)
187  const candidate = trimSlash(path)
188  if (candidate === root || !isUnder(candidate, root)) return null
189  const slug = candidate.slice(root.length + 1).split('/')[0] ?? ''
190  return slug === '' ? null : `${root}/${slug}`
191}
192
193/** The first worktree a command's `cd` or `git worktree add` claims in the layout. */
194export function ownerClaimFrom(claims: readonly string[], worktreeRoot: string | null): string | null {
195  for (const claim of claims) {
196    const worktree = worktreeClaimedBy(claim, worktreeRoot)
197    if (worktree !== null) return worktree
198  }
199  return null
200}
201
202/**
203 * The repo around the session and its role in the loom: `weaver` anywhere in
204 * the repo once a weave was asked for, `owner` inside
205 * `~/.worktrees/<repo>/<slug>`, `none` anywhere else or outside git.
206 */
207export function detectLayout(input: {
208  cwd: string
209  toplevel: string | null
210  commonDir: string | null
211  home: string | null
212  weaveSeen: boolean
213}): Detected {
214  const none: Detected = { canonical: null, ownWorktree: null, worktreeRoot: null, repo: null, role: 'none' }
215  const toplevel = input.toplevel?.trim() ?? ''
216  const canonical = canonicalFrom(input.commonDir, input.cwd)
217  if (toplevel === '' || canonical === null) return none
218  const repo = baseName(canonical)
219  const worktreeRoot = worktreeRootFor(input.home, repo)
220  const top = trimSlash(toplevel)
221  if (input.weaveSeen) return { canonical, ownWorktree: null, worktreeRoot, repo, role: 'weaver' }
222  if (worktreeRoot !== null && dirName(top) === worktreeRoot) {
223    return { canonical, ownWorktree: top, worktreeRoot, repo, role: 'owner' }
224  }
225  return { canonical, ownWorktree: null, worktreeRoot, repo, role: 'none' }
226}
227
228/** The entries of `git worktree list --porcelain`, in git's order. */
229export function parseWorktreeList(porcelain: string): WorktreeEntry[] {
230  const entries: WorktreeEntry[] = []
231  let current: WorktreeEntry | null = null
232  for (const raw of porcelain.split('\n')) {
233    const line = raw.trimEnd()
234    if (line.startsWith('worktree ')) {
235      if (current !== null) entries.push(current)
236      current = { path: line.slice('worktree '.length), head: null, branch: null, isBare: false, isDetached: false }
237      continue
238    }
239    if (current === null) continue
240    if (line.startsWith('HEAD ')) current.head = line.slice('HEAD '.length)
241    else if (line.startsWith('branch ')) current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
242    else if (line === 'bare') current.isBare = true
243    else if (line === 'detached') current.isDetached = true
244  }
245  if (current !== null) entries.push(current)
246  return entries
247}
248
249export function worktreeOfBranch(entries: readonly WorktreeEntry[], branch: string): WorktreeEntry | null {
250  return entries.find(entry => entry.branch === branch) ?? null
251}
252
253export function worktreeAt(entries: readonly WorktreeEntry[], path: string): WorktreeEntry | null {
254  return entries.find(entry => isSamePath(entry.path, path)) ?? null
255}
256
257/** `origin/main` from `git symbolic-ref refs/remotes/origin/HEAD`; `main` when it failed. */
258export function parseDefaultRef(stdout: string | null): string {
259  const line = stdout?.trim().split('\n')[0] ?? ''
260  const short = line.replace(/^refs\/remotes\//, '')
261  return short === '' || short === line ? DEFAULT_BRANCH : short
262}
263
264export function parseCount(stdout: string | null): number | null {
265  const n = Number.parseInt(stdout?.trim() ?? '', 10)
266  return Number.isNaN(n) ? null : n
267}
268
269/** The `/loom` arguments that make the session the weaver: `weave` as the first word. */
270export function isWeaveArgs(args: string): boolean {
271  return /^\s*weave(\s|$)/.test(args)
272}
273
274/** The arguments of a `/loom ...` prompt, or null when the prompt is not one. */
275export function loomArgsOf(text: string): string | null {
276  const match = /^\/loom(?:\s+([\s\S]*))?$/.exec(text.trim())
277  return match === null ? null : (match[1] ?? '').trim()
278}
279
280type Token = { kind: 'word'; text: string } | { kind: 'op'; text: string }
281
282type Tokenized = { tokens: Token[]; isUncertain: boolean }
283
284/**
285 * Splits a shell command into words and operators the way a POSIX shell
286 * would, quotes removed. Gives up (`isUncertain`) on what it cannot follow
287 * without running it: substitutions, heredocs, an unbalanced quote.
288 */
289export function tokenizeShell(command: string): Tokenized {
290  const tokens: Token[] = []
291  const uncertain: Tokenized = { tokens, isUncertain: true }
292  let word = ''
293  let hasWord = false
294  const flush = (): void => {
295    if (!hasWord) return
296    tokens.push({ kind: 'word', text: word })
297    word = ''
298    hasWord = false
299  }
300  const push = (text: string): void => {
301    flush()
302    tokens.push({ kind: 'op', text })
303  }
304  const n = command.length
305  let i = 0
306  while (i < n) {
307    const c = command[i] ?? ''
308    if (c === "'") {
309      const end = command.indexOf("'", i + 1)
310      if (end < 0) return uncertain
311      word += command.slice(i + 1, end)
312      hasWord = true
313      i = end + 1
314      continue
315    }
316    if (c === '"') {
317      let j = i + 1
318      let closed = false
319      while (j < n) {
320        const d = command[j] ?? ''
321        if (d === '\\' && j + 1 < n) {
322          word += command[j + 1]
323          j += 2
324          continue
325        }
326        if (d === '"') {
327          closed = true
328          break
329        }
330        if (d === '`') return uncertain
331        word += d
332        j += 1
333      }
334      if (!closed) return uncertain
335      hasWord = true
336      i = j + 1
337      continue
338    }
339    if (c === '\\') {
340      const escaped = command[i + 1]
341      if (escaped !== undefined && escaped !== '\n') {
342        word += escaped
343        hasWord = true
344      }
345      i += 2
346      continue
347    }
348    if (c === '`' || (c === '$' && command[i + 1] === '(')) return uncertain
349    if (c === '#' && !hasWord) {
350      const end = command.indexOf('\n', i)
351      i = end < 0 ? n : end
352      continue
353    }
354    if (c === ' ' || c === '\t' || c === '\r') {
355      flush()
356      i += 1
357      continue
358    }
359    if (c === '\n') {
360      push('\n')
361      i += 1
362      continue
363    }
364    if (c === '<') {
365      if (command[i + 1] === '<') return uncertain
366      push('<')
367      i += 1
368      continue
369    }
370    if (c === '>') {
371      let op = ''
372      if (hasWord && /^\d$/.test(word)) {
373        op = word
374        word = ''
375        hasWord = false
376      }
377      flush()
378      op += '>'
379      i += 1
380      if (command[i] === '>') {
381        op += '>'
382        i += 1
383      }
384      if (command[i] === '&') {
385        op += '&'
386        i += 1
387      } else if (command[i] === '|') {
388        i += 1
389      }
390      push(op)
391      continue
392    }
393    if (c === '&') {
394      if (command[i + 1] === '&') {
395        push('&&')
396        i += 2
397        continue
398      }
399      if (command[i + 1] === '>') {
400        let op = '&>'
401        i += 2
402        if (command[i] === '>') {
403          op += '>'
404          i += 1
405        }
406        push(op)
407        continue
408      }
409      push('&')
410      i += 1
411      continue
412    }
413    if (c === '|') {
414      const two = command[i + 1] === '|' ? '||' : command[i + 1] === '&' ? '|&' : null
415      push(two ?? '|')
416      i += two === null ? 1 : 2
417      continue
418    }
419    if (c === ';') {
420      const two = command[i + 1] === ';'
421      push(two ? ';;' : ';')
422      i += two ? 2 : 1
423      continue
424    }
425    if (c === '(' || c === ')') {
426      push(c)
427      i += 1
428      continue
429    }
430    word += c
431    hasWord = true
432    i += 1
433  }
434  flush()
435  return { tokens, isUncertain: false }
436}
437
438type Segment = Token[] | '(' | ')'
439
440/** The simple commands in order, with the subshell parentheses kept as markers. */
441function segmentsOf(tokens: readonly Token[]): Segment[] {
442  const segments: Segment[] = []
443  let current: Token[] = []
444  const close = (): void => {
445    if (current.length > 0) segments.push(current)
446    current = []
447  }
448  for (const token of tokens) {
449    if (token.kind === 'op' && (token.text === '(' || token.text === ')')) {
450      close()
451      segments.push(token.text)
452      continue
453    }
454    if (token.kind === 'op' && CONTROL_OPS.has(token.text)) {
455      close()
456      continue
457    }
458    current.push(token)
459  }
460  close()
461  return segments
462}
463
464type Parts = { argv: string[]; assignments: string[]; redirectTargets: string[] }
465
466/** A simple command's argv, its leading assignments, and the files its redirections write. */
467function partsOf(simple: readonly Token[]): Parts {
468  const argv: string[] = []
469  const assignments: string[] = []
470  const redirectTargets: string[] = []
471  for (let i = 0; i < simple.length; i += 1) {
472    const token = simple[i]
473    if (token === undefined) break
474    if (token.kind === 'word') {
475      argv.push(token.text)
476      continue
477    }
478    const target = simple[i + 1]
479    i += 1
480    if (token.text.endsWith('&') || token.text === '<') continue
481    if (target?.kind === 'word') redirectTargets.push(target.text)
482  }
483  while (argv.length > 0) {
484    const match = ASSIGNMENT.exec(argv[0] ?? '')
485    if (match === null) break
486    assignments.push(match[1] ?? '')
487    argv.shift()
488  }
489  return { argv, assignments, redirectTargets }
490}
491
492/** The argv from `git` on, when the command is git by any of its spellings; else null. */
493function gitArgvOf(argv: readonly string[]): readonly string[] | null {
494  const head = argv[0]
495  if (head === undefined) return null
496  if (head === 'git' || (head.includes('/') && baseName(head) === 'git')) return argv
497  return null
498}
499
500type GitCall = { location: string; verb: string | null; args: string[] }
501
502/** `git [-C p] [-c k=v] [--flag] verb args...`; null when the repo it acts on cannot be known. */
503function parseGit(argv: readonly string[], cwd: string, home: string | null): GitCall | null {
504  let location = cwd
505  let i = 1
506  while (i < argv.length) {
507    const arg = argv[i] ?? ''
508    if (arg === '-C' || (arg.startsWith('-C') && arg.length > 2)) {
509      const spelled = arg === '-C' ? argv[i + 1] : arg.slice(2)
510      if (spelled === undefined) return null
511      const resolved = resolvePath(spelled, location, home)
512      if (resolved === null) return null
513      location = resolved
514      i += arg === '-C' ? 2 : 1
515      continue
516    }
517    if (arg === '-c') {
518      i += 2
519      continue
520    }
521    if (/^--(work-tree|git-dir)(=|$)/.test(arg)) return null
522    if (arg.startsWith('-')) {
523      i += 1
524      continue
525    }
526    return { location, verb: arg, args: argv.slice(i + 1) }
527  }
528  return { location, verb: null, args: [] }
529}
530
531/** The operands of a verb with the flag values dropped. */
532function operandsOf(args: readonly string[]): string[] {
533  const operands: string[] = []
534  for (let i = 0; i < args.length; i += 1) {
535    const arg = args[i] ?? ''
536    if (VALUE_FLAGS.has(arg)) {
537      i += 1
538      continue
539    }
540    if (arg.startsWith('-')) continue
541    operands.push(arg)
542  }
543  return operands
544}
545
546function isMutating(call: GitCall): boolean {
547  if (call.verb === null) return false
548  if (call.verb === 'stash') {
549    const sub = operandsOf(call.args)[0]
550    return sub !== 'list' && sub !== 'show'
551  }
552  return MUTATING_GIT_VERBS.has(call.verb)
553}
554
555export type Removal =
556  | { kind: 'branch'; name: string; location: string }
557  | { kind: 'worktree'; path: string; isForce: boolean }
558
559/** What a `git branch -D` or `git worktree remove` names; null when a path cannot be resolved. */
560function removalsOf(call: GitCall, home: string | null): Removal[] | null {
561  const flags = call.args.filter(arg => arg.startsWith('-'))
562  const short = flags.filter(flag => /^-[A-Za-z]+$/.test(flag)).join('')
563  const isForce = short.includes('f') || flags.includes('--force')
564  if (call.verb === 'branch') {
565    const isDelete = short.includes('d') || flags.includes('--delete')
566    if (!(short.includes('D') || (isDelete && isForce))) return []
567    return operandsOf(call.args).map(name => ({ kind: 'branch', name, location: call.location }))
568  }
569  if (call.verb === 'worktree' && call.args[0] === 'remove') {
570    const removals: Removal[] = []
571    for (const spelled of operandsOf(call.args.slice(1))) {
572      const path = resolvePath(spelled, call.location, home)
573      if (path === null) return null
574      removals.push({ kind: 'worktree', path, isForce })
575    }
576    return removals
577  }
578  return []
579}
580
581export type BashAnalysis = {
582  /** True when the command could not be followed; the caller lets it through. */
583  isUncertain: boolean
584  /** Where a mutating git verb acts (its repo, and path operands for the verbs that take them) and what redirections write. */
585  writes: string[]
586  /** The branches and worktrees the command force-deletes or removes. */
587  removals: Removal[]
588  /** Where the command goes: every `cd` target and `git worktree add` path, resolved. */
589  claims: string[]
590}
591
592/**
593 * Reads a Bash command for what it would change on disk: `cd` moves the
594 * working directory for what follows (a subshell's `cd` ends with it),
595 * `git -C` names the repo a verb acts on, `>` names a file. Anything it
596 * cannot follow makes the whole reading uncertain rather than a guess.
597 */
598export function analyzeBash(command: string, env: { cwd: string; home: string | null }): BashAnalysis {
599  const uncertain: BashAnalysis = { isUncertain: true, writes: [], removals: [], claims: [] }
600  const { tokens, isUncertain } = tokenizeShell(command)
601  if (isUncertain) return uncertain
602  const analysis: BashAnalysis = { isUncertain: false, writes: [], removals: [], claims: [] }
603  let cwd = normalizePath(env.cwd)
604  const outer: string[] = []
605  for (const segment of segmentsOf(tokens)) {
606    if (segment === '(') {
607      outer.push(cwd)
608      continue
609    }
610    if (segment === ')') {
611      cwd = outer.pop() ?? cwd
612      continue
613    }
614    const { argv: words, assignments, redirectTargets } = partsOf(segment)
615    for (const target of redirectTargets) {
616      const path = resolvePath(target, cwd, env.home)
617      if (path === null) return uncertain
618      analysis.writes.push(path)
619    }
620    if (assignments.some(name => GIT_ENV.has(name))) return uncertain
621    let argv: string[] = words
622    while (argv.length > 0 && PASSTHROUGH_COMMANDS.has(argv[0] ?? '')) argv = argv.slice(1)
623    const head = argv[0]
624    if (head === undefined) continue
625    if (OPAQUE_COMMANDS.has(head)) return uncertain
626    if (head === 'cd' || head === 'pushd') {
627      const spelled = argv.slice(1).find(arg => arg === '-' || !arg.startsWith('-'))
628      const next = spelled === undefined ? env.home : resolvePath(spelled, cwd, env.home)
629      if (next === null) return uncertain
630      cwd = next
631      analysis.claims.push(cwd)
632      continue
633    }
634    const gitArgv = gitArgvOf(argv)
635    if (gitArgv === null) continue
636    const call = parseGit(gitArgv, cwd, env.home)
637    if (call === null) return uncertain
638    if (isMutating(call)) {
639      analysis.writes.push(call.location)
640      if (call.verb !== null && OPERAND_VERBS.has(call.verb)) {
641        for (const operand of operandsOf(call.args)) {
642          if (!operand.startsWith('/') && !operand.startsWith('~')) continue
643          const path = resolvePath(operand, call.location, env.home)
644          if (path !== null) analysis.writes.push(path)
645        }
646      }
647    }
648    if (call.verb === 'worktree' && call.args[0] === 'add') {
649      const spelled = operandsOf(call.args.slice(1))[0]
650      if (spelled !== undefined) {
651        const path = resolvePath(spelled, call.location, env.home)
652        if (path === null) return uncertain
653        analysis.claims.push(path)
654      }
655    }
656    const removals = removalsOf(call, env.home)
657    if (removals === null) return uncertain
658    analysis.removals.push(...removals)
659  }
660  return analysis
661}
662
663/**
664 * Whether `git worktree remove` needs a word first: git itself refuses a
665 * dirty tree without `--force`, so only a forced removal of a dirty tree, or
666 * a detached head whose commits sit on no branch, can lose work.
667 */
668export function shouldAskWorktreeRemoval(input: {
669  isForce: boolean
670  isDirty: boolean
671  isDetached: boolean
672  ahead: number | null
673}): boolean {
674  if (input.isForce && input.isDirty) return true
675  return input.isDetached && input.ahead !== null && input.ahead > 0
676}
677
678/**
679 * Whether `git branch -D` needs a word first: when the branch's commits exist
680 * nowhere else (ahead of the default branch and not on `origin/<branch>`,
681 * `remoteAhead` null meaning no such upstream), or its worktree is dirty.
682 */
683export function shouldAskBranchDeletion(input: {
684  ahead: number | null
685  remoteAhead: number | null
686  isWorktreeDirty: boolean
687}): boolean {
688  if (input.isWorktreeDirty) return true
689  const isUnpushed = input.remoteAhead === null || input.remoteAhead > 0
690  return isUnpushed && input.ahead !== null && input.ahead > 0
691}
692
693/**
694 * The status line for a role, or undefined (clear) when the session has
695 * none. The engine prefixes the line with the mod's name, so the text
696 * never repeats it: `owner feat-pane`, `weaver · 3 worktrees`.
697 */
698export function statusText(role: Role, layout: Layout): string | undefined {
699  if (role === 'owner') {
700    return `owner ${layout.ownWorktree === null ? '?' : baseName(layout.ownWorktree)}`
701  }
702  if (role !== 'weaver') return undefined
703  const linked = layout.worktrees.filter(path => !isSamePath(path, layout.canonical))
704  if (linked.length === 0) return 'weaver'
705  const noun = linked.length === 1 ? 'worktree' : 'worktrees'
706  return `weaver · ${linked.length} ${noun}`
707}
708
709/** The question before a removal that would lose work. */
710export function removalQuestion(label: string, ahead: number | null, isDirty: boolean): string {
711  const risks: string[] = []
712  if (ahead !== null && ahead > 0) risks.push(`${ahead} unmerged ${ahead === 1 ? 'commit' : 'commits'}`)
713  if (isDirty) risks.push('uncommitted changes')
714  return `${label} has ${risks.join(' and ')}. Remove anyway?`
715}
716
types/index.d.ts 17 lines
1export type LoomPaneRole = 'owner' | 'weaver' | 'none'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'loom-pane': {
6      role: LoomPaneRole
7      canonical: string | null
8      ownWorktree: string | null
9      worktreeRoot: string | null
10      worktrees: string[]
11      repo: string | null
12      weaveSeen: boolean
13      isInteractive: boolean
14    }
15  }
16}
17