Tracks PRs the session touches, shows their checks above the prompt, toasts on green/red/merged, and stops foreground CI polling loops

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane | ||
live-spinner | Spinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet | ||
pr-watch | Tracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs | ||
house-style | No em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one | ||
handoff | /carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop | ||
agent-jobs | /jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes | ||
zsh-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result | ||
agent-models | Pins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on | ||
worktrees | /worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once | ||
pr-rules | Blocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules on | off | auto` overrides per repo (kept across sessions) |
claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods
Or load from a checkout without installing:
claude --plugin-dir ./context-watch --plugin-dir ./live-spinner
claude plugin validate ./context-watch
claude plugin test ./context-watch
tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.
hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TrackedPr, Verdict } from '../types'
5
6const prs = atom({ plugin: 'pr-watch', key: 'prs' } as const, [] as TrackedPr[])
7const isHidden = atom({ plugin: 'pr-watch', key: 'isHidden' } as const, false)
8
9const POLL_MS = 60_000
10const GITHUB_PR = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/g
11const GRAPHITE_PR = /https:\/\/app\.graphite\.(?:dev|com)\/github\/pr\/([\w.-]+)\/([\w.-]+)\/(\d+)/g
12
13// Foreground loops that block the turn waiting on CI or a merge.
14const POLL_LOOP = [
15 /\bgh\s+(pr\s+checks|run\s+watch)\b[^|;&]*--watch\b/,
16 /\bgh\s+run\s+watch\b/,
17 /\b(while|until|for)\b[\s\S]*\bgh\s+(pr|run)\s+(view|checks|list)\b[\s\S]*\bsleep\s+\d+/,
18 /\bsleep\s+\d{2,}\s*(;|&&)\s*gh\s+(pr|run)\b/,
19]
20
21export const findPrUrls = (text: string): string[] => {
22 const urls = new Set<string>()
23 for (const pattern of [GITHUB_PR, GRAPHITE_PR]) {
24 for (const [, owner, repo, number] of text.matchAll(pattern)) {
25 urls.add(`https://github.com/${owner}/${repo}/pull/${number}`)
26 }
27 }
28
29 return [...urls]
30}
31
32export const isPollLoop = (command: string) => POLL_LOOP.some(pattern => pattern.test(command))
33
34type Check = { conclusion?: string | null; state?: string | null; status?: string | null; name?: string; context?: string }
35
36export const summarize = (checks: readonly Check[]) => {
37 let passed = 0
38 let failed = 0
39 let pending = 0
40 const failing: string[] = []
41 for (const check of checks) {
42 const outcome = (check.conclusion || check.state || '').toUpperCase()
43 if (check.status !== undefined && check.status !== null && check.status.toUpperCase() !== 'COMPLETED') {
44 pending += 1
45 } else if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(outcome)) {
46 passed += 1
47 } else if (['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', ''].includes(outcome)) {
48 pending += 1
49 } else {
50 failed += 1
51 failing.push(check.name || check.context || 'unnamed check')
52 }
53 }
54 const verdict: Verdict =
55 failed > 0 ? 'failing' : pending > 0 ? 'pending' : passed > 0 ? 'passing' : 'none'
56
57 return { passed, failed, pending, verdict, failing }
58}
59
60// https://github.com/<owner>/<repo>/pull/<number>
61export const labelOf = (url: string) => {
62 const [, , , , repo, , number] = url.split('/')
63
64 return `${repo}#${number}`
65}
66
67const fetchPr = async ($: EngineInterface, pr: TrackedPr): Promise<TrackedPr> => {
68 const view = await $.process.run(
69 ['gh', 'pr', 'view', pr.url, '--json', 'title,state,statusCheckRollup'],
70 { timeoutMs: 20_000 },
71 )
72 if (view.exitCode !== 0) {
73 return pr
74 }
75
76 const data = JSON.parse(view.stdout) as { title: string; state: TrackedPr['state']; statusCheckRollup: Check[] }
77
78 return { ...pr, title: data.title, state: data.state, ...summarize(data.statusCheckRollup ?? []) }
79}
80
81const announce = ($: EngineInterface, before: TrackedPr, after: TrackedPr) => {
82 if (before.state !== after.state && after.state !== 'OPEN') {
83 $.ui.toast(`${labelOf(after.url)} ${after.state.toLowerCase()}`, { timeoutMs: 10_000 })
84 } else if (before.verdict !== after.verdict && after.verdict === 'failing') {
85 $.ui.toast(`${labelOf(after.url)}: ${after.failed} check${after.failed === 1 ? '' : 's'} failing`, { timeoutMs: 10_000 })
86 } else if (before.verdict !== after.verdict && after.verdict === 'passing') {
87 $.ui.toast(`${labelOf(after.url)}: all checks green`, { timeoutMs: 10_000 })
88 }
89}
90
91const poll = async ($: EngineInterface) => {
92 const list = await read($, prs)
93 const open = list.filter(pr => pr.state === 'OPEN' || pr.state === 'UNKNOWN')
94 for (const pr of open) {
95 const fresh = await fetchPr($, pr).catch(() => pr)
96 announce($, pr, fresh)
97 await update($, prs, all => all.map(one => (one.url === fresh.url ? fresh : one)))
98 }
99}
100
101const track = async ($: EngineInterface, urls: string[]) => {
102 const known = new Set((await read($, prs)).map(pr => pr.url))
103 const added = urls.filter(url => !known.has(url))
104 if (added.length === 0) {
105 return
106 }
107
108 const fresh: TrackedPr[] = added.map(url => ({
109 url, title: '', state: 'UNKNOWN', verdict: 'none', passed: 0, failed: 0, pending: 0,
110 }))
111 await update($, prs, all => [...all, ...fresh].slice(-8))
112 await update($, isHidden, () => false)
113 poll($).catch(() => undefined)
114}
115
116// Svg draws as an isolated image, so it takes fixed colors that read on light and dark alike.
117const DOT_HEX = { merged: '#a371f7', closed: '#8b949e', passing: '#3fb950', failing: '#f85149', pending: '#d29922' }
118
119const dotOf = (pr: TrackedPr) =>
120 pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.verdict === 'failing' ? 'failing' : pr.verdict === 'passing' ? 'passing' : 'pending'
121
122// A filled dot for a settled PR, a ring while checks are still running.
123export const dotSvg = (pr: TrackedPr) => {
124 const kind = dotOf(pr)
125 const color = DOT_HEX[kind]
126 const shape = kind === 'pending'
127 ? `<circle cx="5" cy="5" r="3.5" fill="none" stroke="${color}" stroke-width="2"/>`
128 : `<circle cx="5" cy="5" r="4.5" fill="${color}"/>`
129
130 return `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">${shape}</svg>`
131}
132
133const countOf = (pr: TrackedPr) =>
134 pr.state === 'OPEN' || pr.state === 'UNKNOWN' ? `${pr.passed}/${pr.passed + pr.failed + pr.pending}` : pr.state.toLowerCase()
135
136const MARK: Record<Verdict, string> = { passing: 'ok', failing: 'FAIL', pending: '...', none: '-' }
137
138export const register: Register = on => {
139 on('session.start', async ($, e, next) => {
140 await $.command.register({ name: 'prs', description: 'Tracked PRs and their checks. /prs add <url> | /prs clear' })
141 $.clock.every(POLL_MS, () => {
142 poll($).catch(() => undefined)
143 })
144
145 return next(e)
146 })
147
148 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
149 if (e.run_in_background !== true && isPollLoop(e.command)) {
150 const tracked = (await read($, prs)).map(pr => labelOf(pr.url))
151 const watching = tracked.length > 0 ? ` It is tracking ${tracked.join(', ')}.` : ''
152
153 return {
154 deny: `pr-watch: don't block the turn polling CI or merge state. The pr-watch mod polls tracked PRs every minute and notifies the user on green, red or merged.${watching} If you must wait on it, rerun with run_in_background: true (or use Monitor) and keep working.`,
155 }
156 }
157
158 const ran = await next(e)
159 const urls = findPrUrls(`${e.command}\n${ran.text ?? ''}`)
160 if (urls.length > 0 && /\b(gh\s+pr\s+(create|view|checks|merge|ready)|gt\s+(submit|ss|merge)|git\s+push)\b/.test(e.command)) {
161 await track($, urls)
162 }
163
164 return ran
165 })
166
167 on('command.run', { command: 'prs' }, async ($, e) => {
168 const [verb, ...rest] = e.args.trim().split(/\s+/)
169 if (verb === 'clear') {
170 await update($, prs, () => [])
171
172 return { text: 'Stopped tracking all PRs.' }
173 }
174 if (verb === 'add') {
175 const urls = findPrUrls(rest.join(' '))
176 await track($, urls)
177
178 return { text: urls.length > 0 ? `Tracking ${urls.map(labelOf).join(', ')}.` : 'No PR URL found.' }
179 }
180
181 await poll($)
182 const list = await read($, prs)
183 await update($, isHidden, () => false)
184 if (list.length === 0) {
185 return { text: 'No PRs tracked. They are picked up from gh pr / gt submit / git push output, or /prs add <url>.' }
186 }
187
188 return {
189 text: list
190 .map(pr => `${labelOf(pr.url).padEnd(24)} ${pr.state.padEnd(7)} ${MARK[pr.verdict].padEnd(5)} ${pr.passed} ok, ${pr.failed} fail, ${pr.pending} pending ${pr.title}`)
191 .join('\n'),
192 }
193 })
194
195 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196 const list = await read($, prs)
197 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) {
198 return next(e)
199 }
200
201 if (e.surface === 'desktop') {
202 const { Box, Text, Button, Link, Svg } = $.ui.resolve(e)
203 const failingOf = (pr: TrackedPr) => (pr.failing ?? []).slice(0, 3).join(', ') + ((pr.failing?.length ?? 0) > 3 ? ` +${(pr.failing?.length ?? 0) - 3}` : '')
204
205 return (
206 <Box alignItems="center" columnGap={2}>
207 <Text dimColor>PRs</Text>
208 {list.slice(-4).map(pr => (
209 <Box key={`pr-${labelOf(pr.url)}`} alignItems="center" columnGap={1}>
210 <Svg source={dotSvg(pr)} alt={dotOf(pr)} width={10} height={10} />
211 <Link href={pr.url} label={labelOf(pr.url)} />
212 <Text dimColor>{countOf(pr)}</Text>
213 <Box display="none" hover={{ display: 'flex' }} columnGap={1}>
214 {pr.title !== '' ? <Text dimColor>{pr.title}</Text> : null}
215 {pr.failed > 0 && failingOf(pr) !== '' ? <Text color="red">failing: {failingOf(pr)}</Text> : null}
216 </Box>
217 </Box>
218 ))}
219 <Button key="hide" label="Hide" role="dismiss" onPress={() => update($, isHidden, () => true)} />
220 </Box>
221 )
222 }
223
224 const { Box, Text, Button } = $.ui.resolve(e)
225 const colorOf = (pr: TrackedPr) =>
226 pr.state === 'MERGED' ? 'magenta' : pr.verdict === 'failing' ? 'red' : pr.verdict === 'passing' ? 'green' : 'yellow'
227
228 return (
229 <Box>
230 <Text dimColor>PRs </Text>
231 {list.slice(-4).map(pr => (
232 <Text color={colorOf(pr)}>
233 {labelOf(pr.url)} {countOf(pr)}{' '}
234 </Text>
235 ))}
236 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
237 </Box>
238 )
239 })
240}
241types/index.d.ts 20 lines1export type Verdict = 'pending' | 'passing' | 'failing' | 'none'
2
3export type TrackedPr = {
4 url: string
5 title: string
6 state: 'OPEN' | 'MERGED' | 'CLOSED' | 'UNKNOWN'
7 verdict: Verdict
8 passed: number
9 failed: number
10 pending: number
11 /** Names of the failing checks; absent on PRs tracked before 0.2.0. */
12 failing?: string[]
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'pr-watch': { prs: TrackedPr[]; isHidden: boolean }
18 }
19}
20