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

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.

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`.
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.
| Mod | Where | What it shows or does |
|---|---|---|
on-me | Band, left | One 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-meter | Band, right edge | A 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-state | Pane, /ship-state | Each 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-guard | Hook on Chrome tools | Pairs 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-fleet | Pane, /herdr-fleet | Every 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. |
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.
hooks/register.tsx 193 lines1import { 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}
193hooks/parse.ts 26 lines1// 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
26types/index.d.ts 22 lines1export 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