SLOPSHOPPER

branch-status

A small git-flow graph of main, develop and your branch, with what is waiting to release

newpaneguardcommandprocesstimer
★ 1v0.2.5MITupdated 2026-10-05Spardutti/claude-mods/plugins/branch-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · branch-status
│ ┃ Git ✕ › fix the failing auth test and add an audit log call │ ┃ ● main │ ┃ └─● develop ⏺ Read(src/auth.ts) │ ┃ │ 2 to release ⎿ Read 6 lines │ ┃ │ · fix: refresh expired tokens (2 minutes ⏺ Update(src/auth.ts) │ ┃ │ · feat: add audit log (3 hours ago) ⎿ Added 2 lines, removed 1 line │ ┃ └─● feat/auth-refresh ← you ⏺ Bash(bun test) │ ┃ no commits yet ⎿ 3 pass, 1 fail │ ┃ · a1b2c3d fix: refresh expired tokens │ ┃ minutes ago) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · 9f8e7d6 feat: add audit log (3 hours │ ✻ Worked for 42s · done 4:20 PM │ │ › /branch-status │ ⎿ branch-status: Git panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git
● main └─● develop │ 2 to release │ · fix: refresh expired tokens (2 minutes ago) │ · feat: add audit log (3 hours ago) └─● feat/auth-refresh ← you no commits yet · a1b2c3d fix: refresh expired tokens (2 minutes ago) · 9f8e7d6 feat: add audit log (3 hours ago)
README

claude-mods

Little mods for Claude Code: panels that keep the stuff you'd normally go check next to the chat, and helpers that do the chores for you.

Claude Code 2.1.287+ mods: 4 License: MIT

Why

You spend all day in Claude Code. But the small stuff keeps pulling you out of it: checking git, rescuing a session that got too long, doing the same cleanup by hand.

These mods put that stuff inside Claude Code. Some draw a live panel next to the chat. Some do a chore for you at the right moment. You keep your eyes on the work.

Each one installs on its own, so you take only what you want.

Get started

Add the marketplace once, then install a mod:

/plugin marketplace add spardutti/claude-mods
/plugin install branch-status@spardutti-mods
/plugin install auto-handoff@spardutti-mods
/plugin install handoff-pickup@spardutti-mods
/plugin install blocked-run@spardutti-mods

Install any you like. Each one starts working on the next session start.

The mods

Each mod has its own page with the full story.

ModWhat it does
branch-statusA panel with your branch, develop and main as a tiny git-flow graph, so you see what's waiting to ship without running git log.
auto-handoffWhen the context gets too full, it warns you, counts down, then hands the work to a fresh session.
handoff-pickupReminds you of a handoff you left behind. /pickup picks it up, /pickup done deletes it.
blocked-runWhen auto mode blocks a command, it shows above the prompt with a button to run it yourself.
/plugin marketplace update spardutti-mods
/plugin update branch-status@spardutti-mods
/plugin update auto-handoff@spardutti-mods
/plugin update handoff-pickup@spardutti-mods
/plugin update blocked-run@spardutti-mods
/reload-plugins
Source 6 files
hooks/register.tsx 108 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { drawLanes, shortSubject } from './graph'
6import type { Lane } from './graph'
7import { BASE, RELEASE, collect } from './snapshot'
8
9const PANE = 'branch-status'
10const PANE_COLUMNS = 56
11const snapshot = atom({ plugin: 'branch-status', key: 'snapshot' } as const, { branch: '' } as Snapshot)
12
13let isRefreshing = false
14
15async function refresh($: EngineInterface) {
16  if (isRefreshing) return
17  isRefreshing = true
18  try {
19    const next = await collect(async args => {
20      const { exitCode, stdout } = await $.process.run(['git', ...args])
21      return exitCode === 0 ? stdout : undefined
22    })
23    await update($, snapshot, () => next)
24  } finally {
25    isRefreshing = false
26  }
27}
28
29const commitCount = (n: number) => `${n} commit${n === 1 ? '' : 's'}`
30
31function releaseLane(release: NonNullable<Snapshot['release']>, branch: string): Lane {
32  return {
33    name: RELEASE,
34    color: 'yellow',
35    depth: 1,
36    note: release.merges.length ? { text: `${release.merges.length} to release`, color: 'yellow' } : { text: 'all released', dimColor: true },
37    commits: release.merges.map(shortSubject),
38    warning: release.behind ? `${release.behind} on ${BASE}, not on ${RELEASE}` : undefined,
39    isYou: branch === RELEASE,
40  }
41}
42
43function workLane(branch: string, work: NonNullable<Snapshot['work']>, depth: number): Lane {
44  const lane = { name: branch, color: 'blue', depth, isYou: true }
45  if (work.isMerged) return { ...lane, note: { text: `merged into ${work.parent}`, color: 'green' }, hint: `switch to ${work.parent} and pull` }
46  return {
47    ...lane,
48    note: { text: work.ahead ? commitCount(work.ahead) : 'no commits yet', dimColor: true },
49    commits: work.commits,
50    total: work.ahead,
51    warning: work.behind ? `${work.parent} has ${work.behind} new, pull it` : undefined,
52  }
53}
54
55function lanesOf({ branch, release, work, others = [] }: Snapshot): Lane[] {
56  const depth = release ? 2 : 1
57  const open = others.map(o => ({ name: o.name, color: 'magenta', depth, note: { text: commitCount(o.ahead), dimColor: true }, commits: o.commits, total: o.ahead }))
58  return [
59    { name: BASE, color: 'green', depth: 0, isYou: branch === BASE },
60    ...(release ? [releaseLane(release, branch)] : []),
61    ...(work ? [workLane(branch, work, depth)] : []),
62    ...open,
63  ]
64}
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    await $.command.register({ name: 'branch-status', description: 'Show where this branch stands against main' })
69    void $.ui.open({ id: PANE, title: 'Git', columns: PANE_COLUMNS })
70    void refresh($)
71    $.clock.every(30_000, () => void refresh($))
72
73    return next(e)
74  })
75
76  on('command.run', { command: 'branch-status' }, async $ => {
77    await $.ui.open({ id: PANE, title: 'Git', columns: PANE_COLUMNS })
78    await refresh($)
79
80    return { text: 'Git panel opened.' }
81  })
82
83  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
84    const ran = await next(e)
85    if (/\b(git|gh)\s/.test(e.command)) void refresh($)
86
87    return ran
88  })
89
90  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91    const { Box, Text } = $.ui.resolve(e)
92    const state = await read($, snapshot)
93    if (state.error) return <Text dimColor>{state.error}</Text>
94    if (!state.hasBase) return <Text dimColor>No {BASE} branch here.</Text>
95
96    return (
97      <Box flexDirection="column">
98        {drawLanes(lanesOf(state)).map(({ lead, tail }) => (
99          <Box>
100            <Box flexShrink={0}><Text>{lead.map(({ text, ...style }) => <Text {...style}>{text}</Text>)}</Text></Box>
101            <Box flexShrink={1}><Text>{tail.map(({ text, ...style }) => <Text {...style}>{text}</Text>)}</Text></Box>
102          </Box>
103        ))}
104      </Box>
105    )
106  })
107}
108
hooks/graph.ts 85 lines
1export type Segment = { text: string; color?: string; bold?: boolean; dimColor?: boolean }
2
3export type Row = { lead: Segment[]; tail: Segment[] }
4
5export type Lane = {
6  name: string
7  color: string
8  depth: number
9  note?: Segment
10  commits?: string[]
11  total?: number
12  hint?: string
13  warning?: string
14  isYou?: boolean
15}
16
17const STEP = '  '
18const MAX_COMMITS = 5
19
20function laterSibling(lanes: Lane[], i: number): Lane | undefined {
21  const depth = lanes[i]?.depth ?? 0
22  for (const lane of lanes.slice(i + 1)) {
23    if (lane.depth < depth) return undefined
24    if (lane.depth === depth) return lane
25  }
26  return undefined
27}
28
29function ancestorIndex(lanes: Lane[], i: number, depth: number) {
30  let j = i
31  while (j > 0 && (lanes[j]?.depth ?? 0) > depth) j -= 1
32  return j
33}
34
35// One column per level: a line runs down where that level still has a branch to come.
36function rail(lanes: Lane[], i: number, levels: number): Segment[] {
37  return Array.from({ length: levels }, (_, k) => {
38    const sibling = laterSibling(lanes, ancestorIndex(lanes, i, k + 1))
39    return sibling ? { text: '│ ', color: sibling.color } : { text: STEP }
40  })
41}
42
43function nodeLead(lanes: Lane[], i: number, lane: Lane): Segment[] {
44  if (lane.depth === 0) return [{ text: '● ', color: lane.color }]
45  const fork = laterSibling(lanes, i) ? '├─● ' : '└─● '
46  return [...rail(lanes, i, lane.depth - 1), { text: fork, color: lane.color }]
47}
48
49function notesLead(lanes: Lane[], i: number, lane: Lane): Segment[] {
50  const child = lanes[i + 1]?.depth === lane.depth + 1 ? lanes[i + 1] : undefined
51  return [...rail(lanes, i, lane.depth), child ? { text: '│ ', color: child.color } : { text: STEP }]
52}
53
54function commitRows(lead: Segment[], lane: Lane): Row[] {
55  const commits = lane.commits ?? []
56  const more = (lane.total ?? commits.length) - MAX_COMMITS
57  const rows = commits.slice(0, MAX_COMMITS).map(text => ({ lead: [...lead, { text: '· ', dimColor: true }], tail: [{ text }] }))
58  return more > 0 ? [...rows, { lead, tail: [{ text: `+${more} more`, dimColor: true }] }] : rows
59}
60
61function laneRows(lanes: Lane[], i: number, lane: Lane): Row[] {
62  const name = { text: lane.name, color: lane.color, bold: lane.isYou }
63  const node = { lead: nodeLead(lanes, i, lane), tail: lane.isYou ? [name, { text: '  ← you', bold: true }] : [name] }
64  const lead = notesLead(lanes, i, lane)
65  const line = (segment?: Segment) => (segment ? [{ lead, tail: [segment] }] : [])
66  return [
67    node,
68    ...line(lane.note),
69    ...commitRows(lead, lane),
70    ...line(lane.hint ? { text: lane.hint, dimColor: true } : undefined),
71    ...line(lane.warning ? { text: lane.warning, color: 'red' } : undefined),
72  ]
73}
74
75// Lanes come in tree order, each with its depth; notes sit beneath their lane, so depth costs little width.
76export function drawLanes(lanes: Lane[]): Row[] {
77  return lanes.flatMap((lane, i) => laneRows(lanes, i, lane))
78}
79
80export function shortSubject(subject: string) {
81  const pr = subject.match(/^Merge pull request (#\d+) from [^/]+\/(.+)$/)
82  if (pr) return `${pr[1]}  ${pr[2]}`
83  return subject.match(/^Merge branch '([^']+)'/)?.[1] ?? subject
84}
85
hooks/snapshot.ts 90 lines
1import type { OpenBranch, Snapshot } from '../types'
2import { openCandidates, parseBranches } from './branches'
3import { parseCounts } from './counts'
4
5export const BASE = 'main'
6export const RELEASE = 'develop'
7const MAX_COMMITS = 5
8
9export type Git = (args: string[]) => Promise<string | undefined>
10
11async function hasRef(git: Git, ref: string) {
12  return (await git(['rev-parse', '--verify', '-q', ref])) !== undefined
13}
14
15async function drift(git: Git, from: string, to: string) {
16  return parseCounts((await git(['rev-list', '--no-merges', '--left-right', '--count', `${from}...${to}`])) ?? '') ?? { ahead: 0, behind: 0 }
17}
18
19async function commitsOf(git: Git, from: string, to: string) {
20  return ((await git(['log', '--no-merges', '--format=%s', '-n', String(MAX_COMMITS), `${from}..${to}`])) ?? '').split('\n').filter(Boolean)
21}
22
23// A merge whose side branch holds no commits missing from base (a sync from main) has nothing to release.
24async function bringsWork(git: Git, base: string, hash: string) {
25  return (await git(['rev-list', '--no-merges', '--count', `${base}..${hash}^2`]))?.trim() !== '0'
26}
27
28async function unreleased(git: Git, base: string, ref: string) {
29  const lines = ((await git(['log', '--first-parent', '--format=%H %s', `${base}..${ref}`])) ?? '').split('\n').filter(Boolean)
30  const kept: string[] = []
31  for (const line of lines) {
32    const [hash = '', ...subject] = line.split(' ')
33    if (await bringsWork(git, base, hash)) kept.push(subject.join(' '))
34  }
35  return kept
36}
37
38// Branches other than yours are read from origin, since local copies of them go stale.
39async function refOf(git: Git, name: string, branch: string) {
40  return name !== branch && (await hasRef(git, `origin/${name}`)) ? `origin/${name}` : name
41}
42
43// Commits whose change is not in ref yet; --cherry-pick also drops ones squash-merged there.
44async function missingFrom(git: Git, ref: string, name: string) {
45  const lines = ((await git(['log', '--no-merges', '--cherry-pick', '--right-only', '--format=%H %s', `${ref}...${name}`])) ?? '').split('\n')
46  return lines.filter(Boolean).map(line => ({ hash: line.slice(0, line.indexOf(' ')), subject: line.slice(line.indexOf(' ') + 1) }))
47}
48
49// A branch merged straight into base, skipping the parent, is done too.
50async function unmergedSubjects(git: Git, refs: { parent: string; base: string }, name: string) {
51  const inBase = new Set((await missingFrom(git, refs.base, name)).map(c => c.hash))
52  return (await missingFrom(git, refs.parent, name)).filter(c => inBase.has(c.hash)).map(c => c.subject)
53}
54
55async function openBranches(git: Git, refs: { parent: string; base: string }, names: string[]) {
56  const open: OpenBranch[] = []
57  for (const name of names) {
58    const subjects = await unmergedSubjects(git, refs, name)
59    if (subjects.length) open.push({ name, ahead: subjects.length, commits: subjects.slice(0, MAX_COMMITS) })
60  }
61  return open
62}
63
64type Parent = { name: string; ref: string }
65
66async function releaseOf(git: Git, base: string, ref: string) {
67  return { ...(await drift(git, base, ref)), merges: await unreleased(git, base, ref) }
68}
69
70async function workOf(git: Git, parent: Parent, isMerged: boolean) {
71  return { parent: parent.name, isMerged, ...(await drift(git, parent.ref, 'HEAD')), commits: await commitsOf(git, parent.ref, 'HEAD') }
72}
73
74export async function collect(git: Git): Promise<Snapshot> {
75  const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD']))?.trim()
76  if (!branch) return { branch: '', error: 'Not a git repository.' }
77  if (!(await hasRef(git, BASE))) return { branch, hasBase: false }
78
79  const base = await refOf(git, BASE, branch)
80  const hasRelease = await hasRef(git, RELEASE)
81  const parent = hasRelease ? { name: RELEASE, ref: await refOf(git, RELEASE, branch) } : { name: BASE, ref: base }
82  const release = hasRelease ? await releaseOf(git, base, parent.ref) : undefined
83  const locals = parseBranches((await git(['for-each-ref', '--format=%(refname:short)%09%(upstream:track)', 'refs/heads'])) ?? '')
84  const isLongLived = branch === BASE || branch === RELEASE
85  const work = isLongLived ? undefined : await workOf(git, parent, locals.find(b => b.name === branch)?.isGone ?? false)
86  const others = await openBranches(git, { parent: parent.ref, base }, openCandidates(locals, branch, [BASE, RELEASE]))
87
88  return { branch, hasBase: true, release, work, others }
89}
90
hooks/branches.ts 20 lines
1export type LocalBranch = { name: string; isGone: boolean }
2
3// Reads `git for-each-ref --format=%(refname:short)%09%(upstream:track) refs/heads`.
4export function parseBranches(stdout: string): LocalBranch[] {
5  return stdout
6    .split('\n')
7    .filter(Boolean)
8    .map(line => {
9      const [name = '', track = ''] = line.split('\t')
10      return { name, isGone: track === '[gone]' }
11    })
12}
13
14// A branch whose upstream is gone was deleted on origin, almost always after its PR merged.
15export function openCandidates(branches: LocalBranch[], current: string, longLived: string[]): string[] {
16  return branches
17    .filter(b => !b.isGone && b.name !== current && !longLived.includes(b.name) && !b.name.startsWith('release/'))
18    .map(b => b.name)
19}
20
hooks/counts.ts 5 lines
1export function parseCounts(stdout: string): { behind: number; ahead: number } | undefined {
2  const [behind = NaN, ahead = NaN] = stdout.trim().split(/\s+/).map(Number)
3  return Number.isFinite(behind) && Number.isFinite(ahead) ? { behind, ahead } : undefined
4}
5
types/index.d.ts 19 lines
1export type Drift = { ahead: number; behind: number }
2
3export type OpenBranch = { name: string; ahead: number; commits: string[] }
4
5export type Snapshot = {
6  branch: string
7  hasBase?: boolean
8  release?: Drift & { merges: string[] }
9  work?: Drift & { parent: string; commits: string[]; isMerged: boolean }
10  others?: OpenBranch[]
11  error?: string
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'branch-status': { snapshot: Snapshot }
17  }
18}
19