SLOPSHOPPER

ship-state

Pane: uncommitted, unpushed, CI and deploy state of each repo this session touched

newpaneguardcommandtoastprocess
v0.2.0MITupdated 2026-10-05abhibansal60/claude-mods/ship-state
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ship-state
│ ┃ Ship state ✕ › fix the failing auth test and add an audit log call │ ┃ No repo touched yet. │ ┃ [ Refresh ] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ship-state │ ⎿ ship-state: Ship state pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ship state
No repo touched yet. [ Refresh ]
README

claude-mods

Claude Code mods (plugins of function hooks) built from how I actually use Claude Code: two accounts rotated with cswap, many parallel sessions in herdr, and checking results rather than diffs.

on-me and twin-meter in the band above the prompt, ship-state in the side pane, on a real repo

Start here: paste this to your agent

Set up the claude-mods Claude Code plugins from https://github.com/abhibansal60/claude-mods.
1. Read README.md and each mod's hooks/register.tsx (or .ts) on GitHub so you know what each one runs.
2. Check what I have, read-only: `claude --version`, `which cswap herdr gh git`, `gh auth status`, and the plugins in ~/.claude/settings.json.
3. Tell me which mods fit my machine: twin-meter and browser-guard need cswap; herdr-fleet needs herdr; ship-state needs git and gh. Skip the ones whose tools are missing.
4. Ask me before changing anything. Then run `claude plugin marketplace add abhibansal60/claude-mods` and `claude plugin install <mod>@claude-mods --scope user` for each mod I pick.
5. Tell me to restart Claude Code, and how to see each mod: the one-line band above the prompt, `/ship-state`, `/herdr-fleet`, `/browser-guard`.

Install

claude plugin marketplace add abhibansal60/claude-mods
claude plugin install on-me@claude-mods --scope user
claude plugin install twin-meter@claude-mods --scope user
claude plugin install ship-state@claude-mods --scope user
claude plugin install browser-guard@claude-mods --scope user
claude plugin install herdr-fleet@claude-mods --scope user

Then restart Claude Code. Update later with claude plugin marketplace update claude-mods.

Needs: twin-meter and browser-guard use cswap, herdr-fleet uses herdr, ship-state uses git and gh.

The mods

ModWhereWhat it shows or does
on-meBand, leftOne line: a yellow ON YOU tag and the first item from the last answer's Blocked on me part (or "answer my question"), +N for more; a cyan CLAUDE tag and the current tool call while Claude works. Nothing when nothing waits on you.
twin-meterBand, right edgeA magenta OTHER ACCOUNT tag with #2 5h 3% ↺ 45m · 7d 91% for your other cswap accounts (the status line already shows the active one). Turns into a cyan SWITCH tag when the active account passes 60% and another has room.
ship-statePane, /ship-stateEach repo the session touched: branch, uncommitted files, unpushed commits, last CI run, commit statuses on HEAD (Vercel deploys) and PyPI against the local version. A toast when an answer says "done" but a repo is not shipped.
browser-guardHook on Chrome toolsPairs each cswap account with a Chrome browser on first use, then stops Chrome actions from the wrong profile. /browser-guard lists pairs, /browser-guard reset forgets them.
herdr-fleetPane, /herdr-fleetEvery herdr agent with status, folder, title and last words, "◉ you are here" on this pane, a focus button, a toast when another agent is blocked. Stops Bash commands that would close this pane or herdr.

Develop

Each mod is a plugin: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx, a types/index.d.ts contract for its $.state, and pure parsers in hooks/parse.ts with tests in tests/.

claude plugin validate ./<mod>
claude plugin test ./<mod>

To work on a mod live, run claude --plugin-dir ./<mod>: saving a file reloads it.

Source 3 files
hooks/register.tsx 193 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ci, RepoRow } from '../types'
5import { CLAIMS_DONE, SHIPPING, parseStatus, pyproject, repoDirs } from './parse'
6
7const PANE = 'ship-state'
8const roots = atom({ plugin: 'ship-state', key: 'roots' } as const, [])
9const rows = atom({ plugin: 'ship-state', key: 'rows' } as const, [])
10
11let running: Promise<void> | null = null
12let queued: Promise<void> | null = null
13// Folder -> its git root (null: not a repo). Saves a git spawn on every Read/Edit/Bash of a seen folder.
14const rootOf = new Map<string, string | null>()
15
16async function sh($: EngineInterface, argv: string[], cwd: string) {
17  try {
18    return await $.process.run(argv, { cwd, timeoutMs: 20000 })
19  } catch {
20    return { exitCode: 1, stdout: '', stderr: '' }
21  }
22}
23
24// Adds the git root of each dir to the watched list.
25async function track($: EngineInterface, dirs: string[]) {
26  for (const dir of dirs) {
27    if (rootOf.has(dir)) continue
28    const known = await read($, roots)
29    if (known.some((r: string) => dir === r || dir.startsWith(`${r}/`))) {
30      rootOf.set(dir, null)
31      continue
32    }
33    const { exitCode, stdout } = await sh($, ['git', '-C', dir, 'rev-parse', '--show-toplevel'], '/')
34    const root = exitCode === 0 ? stdout.trim() : ''
35    rootOf.set(dir, root || null)
36    if (!root || known.includes(root)) continue
37    await update($, roots, (list: string[]) => (list.includes(root) ? list : [...list, root]))
38    if ((await read($, roots)).length === 1) void $.ui.open({ id: PANE, title: 'Ship state' })
39  }
40}
41
42async function check($: EngineInterface, root: string): Promise<RepoRow> {
43  const status = parseStatus((await sh($, ['git', 'status', '--porcelain=v1', '-b'], root)).stdout)
44  const remote = (await sh($, ['git', 'remote', 'get-url', 'origin'], root)).stdout.trim()
45  const hasRemote = remote.includes('github.com')
46  let ci: Ci | null = null
47  const checks: Record<string, string> = {}
48  if (hasRemote) {
49    const runs = await sh($, ['gh', 'run', 'list', '-L', '1', '--json', 'workflowName,status,conclusion,url'], root)
50    const run = runs.exitCode === 0 ? (parseJson(runs.stdout)?.[0] as Record<string, string> | undefined) : undefined
51    if (run) ci = { name: run.workflowName ?? '', status: run.status ?? '', conclusion: run.conclusion ?? '', url: run.url ?? '' }
52    const sha = (await sh($, ['git', 'rev-parse', 'HEAD'], root)).stdout.trim()
53    const st = await sh($, ['gh', 'api', `repos/{owner}/{repo}/commits/${sha}/status`, '--jq', '.statuses[] | [.context, .state] | @tsv'], root)
54    for (const line of st.stdout.split('\n').filter(Boolean)) {
55      const [context = '', state = ''] = line.split('\t')
56      checks[context] ??= state
57    }
58  }
59  let pypi: RepoRow['pypi'] = null
60  const project = await $.fs.read(`${root}/pyproject.toml`).then(pyproject, () => null)
61  if (project) {
62    const res = await $.http.fetch(`https://pypi.org/pypi/${project.name}/json`).catch(() => null)
63    const live = res?.ok ? ((parseJson(res.text)?.info?.version as string | undefined) ?? '?') : '?'
64    pypi = { local: project.version, live }
65  }
66  return {
67    root,
68    name: root.split('/').pop() ?? root,
69    branch: status.branch,
70    dirty: status.dirty,
71    ahead: status.hasUpstream ? status.ahead : -1,
72    hasRemote,
73    ci,
74    checks,
75    pypi,
76    checkedAt: await $.clock.now(),
77  }
78}
79
80function parseJson(text: string): any {
81  try {
82    return JSON.parse(text || 'null')
83  } catch {
84    return null
85  }
86}
87
88async function refreshNow($: EngineInterface) {
89  const list: RepoRow[] = []
90  for (const root of await read($, roots)) list.push(await check($, root))
91  await update($, rows, () => list)
92}
93
94// One refresh at a time. A call during a refresh waits for one more after it, so callers that read rows
95// afterwards (warnIfUnshipped) always see state from after their call, never an old snapshot.
96function refresh($: EngineInterface): Promise<void> {
97  if (!running) return (running = refreshNow($).finally(() => (running = null)))
98  return (queued ??= running.then(() => {
99    queued = null
100    return refresh($)
101  }))
102}
103
104const isRed = (r: RepoRow) => r.ci?.conclusion === 'failure' || Object.values(r.checks).some(s => s === 'failure' || s === 'error')
105const isUnshipped = (r: RepoRow) => r.ahead !== 0 || r.dirty > 0 || (r.pypi !== null && r.pypi.local !== r.pypi.live)
106const isRunning = (r: RepoRow) => (r.ci !== null && r.ci.status !== 'completed') || Object.values(r.checks).includes('pending')
107
108// Refreshes after the turn without holding it, then warns when the answer claimed a ship that did not happen.
109async function warnIfUnshipped($: EngineInterface, claimsDone: boolean) {
110  await refresh($)
111  const bad = (await read($, rows)).filter((r: RepoRow) => isRed(r) || isUnshipped(r))
112  if (claimsDone && bad.length > 0) $.ui.toast(`Said done, but not shipped: ${bad.map((r: RepoRow) => r.name).join(', ')}`)
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    await $.command.register({ name: 'ship-state', description: 'Show git, CI and deploy state of the repos this session touched' })
118    const cwd = (await $.env.get('PWD')) ?? ''
119    void track($, [cwd]).then(() => refresh($))
120    // Poll faster while CI or a deploy runs.
121    $.clock.every(30_000, async () => {
122      const list = await read($, rows)
123      if (list.some(isRunning)) await refresh($)
124    })
125    $.clock.every(300_000, () => refresh($))
126    return next(e)
127  })
128
129  on('command.run', { command: 'ship-state' }, async $ => {
130    await $.ui.open({ id: PANE, title: 'Ship state' })
131    void refresh($)
132    return { text: 'Ship state pane opened.' }
133  })
134
135  on('tool.call', async ($, e, next) => {
136    const ran = await next(e)
137    const input = e as unknown as { command?: string; file_path?: string; notebook_path?: string }
138    const text = input.command ?? input.file_path ?? input.notebook_path
139    if (text) {
140      const home = (await $.env.get('HOME')) ?? ''
141      await track($, repoDirs(text, home))
142      if (input.command && SHIPPING.test(input.command)) void refresh($)
143    }
144    return ran
145  })
146
147  on('turn.complete', async ($, e, next) => {
148    if (!e.agentId) void warnIfUnshipped($, CLAIMS_DONE.test(e.answer))
149    return next(e)
150  })
151
152  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
153    const { Box, Button, Text } = $.ui.resolve(e)
154    const list = await read($, rows)
155    return (
156      <Box flexDirection="column">
157        {list.length === 0 && <Text dimColor>No repo touched yet.</Text>}
158        {list.map((r: RepoRow) => (
159          <Box key={r.root} flexDirection="column" marginBottom={1}>
160            <Text bold color={isRed(r) ? 'red' : isUnshipped(r) ? 'yellow' : 'green'}>
161              {isRed(r) ? '✗' : isUnshipped(r) ? '●' : '✓'} {r.name} <Text dimColor>{r.branch}</Text>
162            </Text>
163            <Text>
164              {r.dirty > 0 ? `✎ ${r.dirty} uncommitted  ` : ''}
165              {r.ahead > 0 ? `↑ ${r.ahead} unpushed  ` : ''}
166              {r.ahead === -1 ? 'no upstream  ' : ''}
167              {!r.hasRemote ? 'no GitHub remote' : ''}
168              {r.dirty === 0 && r.ahead === 0 && r.hasRemote ? 'clean, pushed' : ''}
169            </Text>
170            {r.ci && (
171              <Text color={r.ci.conclusion === 'failure' ? 'red' : r.ci.status !== 'completed' ? 'yellow' : undefined}>
172                CI {r.ci.status === 'completed' ? (r.ci.conclusion === 'success' ? '✓' : `✗ ${r.ci.conclusion}`) : '⋯ running'} {r.ci.name}
173              </Text>
174            )}
175            {r.ci && <Text dimColor wrap="truncate-start">{r.ci.url}</Text>}
176            {Object.entries(r.checks).map(([context, state]) => (
177              <Text key={context} color={state === 'success' ? undefined : state === 'pending' ? 'yellow' : 'red'}>
178                {state === 'success' ? '✓' : state === 'pending' ? '⋯' : '✗'} {context}
179              </Text>
180            ))}
181            {r.pypi && (
182              <Text color={r.pypi.local === r.pypi.live ? undefined : 'yellow'}>
183                PyPI {r.pypi.live} {r.pypi.local === r.pypi.live ? '= local' : `≠ local ${r.pypi.local}`}
184              </Text>
185            )}
186          </Box>
187        ))}
188        <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
189      </Box>
190    )
191  })
192}
193
hooks/parse.ts 26 lines
1// Every ~/code/<repo> a command or path names.
2export function repoDirs(text: string, home: string): string[] {
3  const found = new Set<string>()
4  const re = new RegExp(`(?:~|${home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})/code/([\\w.-]+)`, 'g')
5  for (const m of text.matchAll(re)) found.add(`${home}/code/${m[1]}`)
6  return [...found]
7}
8
9// `git status --porcelain=v1 -b`: "## main...origin/main [ahead 2, behind 1]" then one line per file.
10export function parseStatus(out: string): { branch: string; ahead: number; dirty: number; hasUpstream: boolean } {
11  const [head = '', ...files] = out.split('\n')
12  const branch = head.replace(/^## /, '').split('...')[0]!.replace(/^No commits yet on /, '')
13  const ahead = Number(head.match(/ahead (\d+)/)?.[1] ?? 0)
14  return { branch, ahead, dirty: files.filter(Boolean).length, hasUpstream: head.includes('...') }
15}
16
17export function pyproject(text: string): { name: string; version: string } | null {
18  const project = text.split(/^\[project\]\s*$/m)[1]?.split(/^\[/m)[0] ?? ''
19  const name = project.match(/^name\s*=\s*"([^"]+)"/m)?.[1]
20  const version = project.match(/^version\s*=\s*"([^"]+)"/m)?.[1]
21  return name && version ? { name, version } : null
22}
23
24export const SHIPPING = /\b(git|gh|vercel|npm|pnpm|twine|uv|wrangler|firebase)\b/
25export const CLAIMS_DONE = /\b(done|deployed|merged|pushed|shipped|published|live now|is live)\b/i
26
types/index.d.ts 22 lines
1export type Ci = { name: string; status: string; conclusion: string; url: string }
2
3export type RepoRow = {
4  root: string
5  name: string
6  branch: string
7  dirty: number
8  ahead: number
9  hasRemote: boolean
10  ci: Ci | null
11  // Commit statuses on HEAD, such as Vercel's deploy: context → state
12  checks: Record<string, string>
13  pypi: { local: string; live: string } | null
14  checkedAt: number
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'ship-state': { roots: string[]; rows: RepoRow[] }
20  }
21}
22