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.

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.
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:
~/.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>./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.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.
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.
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.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.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.
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.
| Field | Type | Default | Meaning |
|---|---|---|---|
guards | boolean | true | run the three guards above |
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.
hooks/register.tsx 274 lines1import { 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}
274lib/loom.ts 716 lines1// 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}
716types/index.d.ts 17 lines1export 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