Shows whether the GitHub Actions run for your branch is running, passed or failed after you push, in the HUD. Needs the HUD plugin and the GitHub CLI signed in.

Shows whether the GitHub Actions run for your branch is running, passed or failed, in the HUD, from the moment you push until it finishes.
| CI | Note |
|---|---|
running | Build and test on feat/x |
It sends the HUD one slot, hud-ci/run, a stat. The value is running, passed or failed, with a tone to match (info, good, bad), and the note names the workflow and the branch.
running while the run is in progress, queued while it waits.passed when it concluded success, failed when it concluded failure, timed_out or startup_failure.cancelled, skipped, action required), in the warning tone. It is neither passed nor failed.gh isn't installed or signed in, the folder has no branch (a detached head, no repository) or the branch has no run of this push's. After a push the slot is taken away and nothing is said; after a gh pr command the last result stays as it was.After a finished Bash or PowerShell command that is a git push (also git -C dir push, in a chain, with options) or a gh pr command. It reads the session folder's repository, so a push from another folder follows the session folder's branch. A push that failed starts nothing. The command is read word by word, by no pattern that can backtrack, so a long or odd command costs no more than its length.
git branch --show-current.running.A second push ends the first watch and follows the new one. While a watch is live only a push restarts it: gh pr view, checks, status, create and the rest leave it be (it follows the branch's latest run all the same). With no watch live, any gh pr command starts one, which never takes the last result away: only a run GitHub created since the command began, a newer one (gh pr create makes one), replaces it. Only a push starts fresh. The session ending ends the watch.
Its access line, as the HUD shows it beside the slot and in /hud sources:
Runs
git branch --show-currentand the GitHub CLI (gh run list) for this repository's branch after you push or rungh pr. Needsghsigned in; reads the branch name and run status only.
To check it yourself, run claude plugin validate sources/hud-ci, which lists the hooks and calls: $.process.run, $.clock.every and $.clock.now, and the $.hud calls to declare, push and clear its slot.
git branch --show-current (2-second limit) and gh run list --branch <branch> --limit 1 --json status,conclusion,name,createdAt (15-second limit). The branch is one argument, never parsed by a shell.gh run list uses the GitHub CLI's own sign-in to ask GitHub for run status over the network. The source holds no token and reads no secret.gh fails, the source does nothing and the tool call it followed returns as it would have.It needs the HUD plugin, and doesn't load without it. It also needs the GitHub CLI signed in (gh auth status). In Claude Code:
/plugin install hud --marketplace kyledarling-io/claude-code-desktop-hud
/plugin install hud-ci --marketplace kyledarling-io/claude-code-desktop-hud
Or in the Claude Desktop app, add the marketplace as the README says and install hud-ci beside hud.
It works where Claude Code lets a plugin run a process. That isn't confirmed in the desktop app yet. Where it can't run one, it says nothing and the HUD shows no CI.
/hud mute hud-ci hides the run from the strip and the dashboard, now and in later sessions. /hud unmute hud-ci brings it back. /hud sources lists every source by name. Muting hides the slot; the source still asks gh after a push. Uninstall it to stop that.
claude plugin validate sources/hud-ci checks the manifest and hooks. Its tests, hooks/ci.test.ts, load it beside the HUD as another plugin would be, answer $.process themselves and move the clock by hand, so they need no gh, no network and no secret, and run with the rest from the repo root: claude plugin test ..
The hooks module is one register function with everything inside it, because a test loads it as an inline plugin and an inline plugin is self-contained.
hooks/register.ts 141 lines1import type { Register } from 'claude-code'
2
3// The CI source: after a push, the latest GitHub Actions run of the current branch, followed until it finishes, sent
4// to the HUD as one slot (`hud-ci/run`). Everything lives inside `register`, because a test loads this function as an
5// inline plugin and an inline plugin is self-contained. Every call it makes is caught: a source never throws into its
6// own hooks, and a missing `gh`, one that isn't signed in, a folder with no branch or a HUD that refuses means the
7// slot goes and nothing is said.
8export const register: Register = on => {
9 const declaration = {
10 v: 1 as const,
11 name: 'CI',
12 access: "Runs `git branch --show-current` and the GitHub CLI (`gh run list`) for this repository's branch after you push or run `gh pr`. Needs `gh` signed in; reads the branch name and run status only.",
13 slots: [{ id: 'run', pattern: 'stat' as const, title: 'CI' }],
14 }
15 const POLL = 30_000
16 const CAP = 20 * 60_000 // follows a run this long at most
17 const WAIT = 2 * 60_000 // a push's own run is expected within this; none by then (no CI here) and it stops
18 // GitHub stamps a run with its own clock and this machine's may differ; a run counts as the command's if it was created
19 // after the command began, less this. simplification: a push a minute after another can show the first one's run until its
20 // own appears, and a gh pr command a minute after a run takes that run as newer (shown again, unchanged)
21 const SKEW = 60_000
22
23 // simplification: words, not a shell parser, and no pattern that can backtrack (this runs before the person's own command
24 // does): the command is cut into steps and each step walked word by word, so a command built to stall a pattern
25 // costs only its length. 'push' is a step that is `git push` (global options before the verb; after env assignments,
26 // a path or a subshell), 'pr' one that is `gh pr`; a miss only means no watch.
27 const kind = (command: string) => {
28 let found = ''
29 for (const step of command.split(/&&|\|\||[;|\n]/)) {
30 const w = step.replace(/^[\s(&]+/, '').split(/\s+/)
31 let i = w.findIndex(x => !/^\w+=/.test(x))
32 const program = (w[i] ?? '').split(/[\\/]/).at(-1)?.toLowerCase().replace(/\.exe$/, '')
33 if (program === 'gh' && w[i + 1] === 'pr') found = 'pr'
34 if (program === 'git') {
35 for (i++; w[i]?.startsWith('-'); i++) if (w[i] === '-C' || w[i] === '-c') i++ // these two take the next word
36 if (w[i] === 'push') return 'push'
37 }
38 }
39 return found
40 }
41
42 // `gh run list --json status,conclusion,name,createdAt` → its newest run ([] when there is none), or undefined
43 // when it isn't that at all.
44 const parse = (out: string) => {
45 try {
46 const list: unknown = JSON.parse(out)
47 if (!Array.isArray(list)) return undefined
48 if (list.length === 0) return []
49 const r: unknown = list[0]
50 if (typeof r !== 'object' || r === null) return undefined
51 const { status, conclusion, name, createdAt } = r as Record<string, unknown>
52 const at = Date.parse(String(createdAt))
53 if (typeof status !== 'string' || Number.isNaN(at)) return undefined
54 return [{ status, conclusion: typeof conclusion === 'string' ? conclusion : '', name: typeof name === 'string' && name ? name : 'Workflow run', at }]
55 } catch { return undefined }
56 }
57
58 // The words are GitHub's own conclusions, not a guess: only success is passed, only a failure of the run itself is
59 // failed; cancelled, skipped, neutral and the rest are named as they are.
60 const slotOf = (r: { status: string; conclusion: string; name: string }, branch: string) => {
61 const [value, tone] = r.status === 'in_progress' ? ['running', 'info' as const]
62 : r.status !== 'completed' ? ['queued', 'info' as const]
63 : r.conclusion === 'success' ? ['passed', 'good' as const]
64 : ['failure', 'timed_out', 'startup_failure'].includes(r.conclusion) ? ['failed', 'bad' as const]
65 : [r.conclusion.replaceAll('_', ' ') || 'ended', 'warn' as const]
66 return { v: 1 as const, id: 'run', pattern: 'stat' as const, title: 'CI', value, tone, note: `${r.name} on ${branch}` }
67 }
68
69 // The watch under way (a timer, or none). Two pushes can come while the first one's branch is still being read, and a
70 // poll can be waiting on gh when the next push comes, so whose turn it is lives in this one variable: a watch ends
71 // another's in the same breath it registers itself (no await between), and a poll that finds it isn't the one
72 // watching drops what it learned. A reload resets it, and takes the timer with it.
73 let watching: { cancel: () => void } | undefined
74
75 // try/catch, not .catch on the call: a call that throws before it returns a promise gets past a .catch (SOURCES.md)
76 on('session.start', async ($, e, next) => {
77 try { await $.hud.declare(declaration) } catch { /* the HUD isn't answering */ }
78 // a reload mid-run leaves the HUD holding a run nothing now watches
79 try { await $.hud.clear({ id: 'run' }) } catch { /* nor here */ }
80 return next(e)
81 })
82
83 // the session over, nothing is left asking gh
84 on('session.end', async ($, e, next) => {
85 watching?.cancel()
86 watching = undefined
87 return next(e)
88 })
89
90 on('tool.call', async ($, e, next) => {
91 const what = e.tool === 'Bash' || e.tool === 'PowerShell' ? kind(e.command) : ''
92 // while a watch is live only a push restarts it: `gh pr view`, `checks`, `status` and the rest leave it be, and
93 // it follows the branch's latest run all the same. With none live, a gh pr command starts one.
94 if (!what || (what === 'pr' && watching)) return next(e)
95 // when the command began, to tell this push's run from an older one
96 const began = await $.clock.now()
97 const ran = await next(e)
98 // a push that failed or was denied made no run
99 if (ran.deny !== undefined || ran.isError === true) return ran
100 const b = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 2000 }).catch(() => undefined)
101 const branch = b?.exitCode === 0 ? b.stdout.trim() : ''
102 if (what === 'pr' && watching) return ran // a push began a watch while this command ran
103 // The newest push is the one followed. The old watch ends and the new one registers with no await between.
104 watching?.cancel()
105 watching = undefined
106 if (branch) {
107 let sent = '' // what the HUD was last sent, so an unchanged run isn't pushed again (a push is a change to the HUD)
108 // A push's watch owns the slot from the start. A gh pr command's owns it only once it shows a run GitHub created
109 // since the command began, a newer one (`gh pr create` makes one): until then the result before it stays, whatever
110 // gh answers.
111 let owns = what === 'push'
112 const timer = $.clock.every(POLL, async () => {
113 const elapsed = (await $.clock.now()) - began
114 const got = await $.process.run(['gh', 'run', 'list', '--branch', branch, '--limit', '1', '--json', 'status,conclusion,name,createdAt'], { timeoutMs: 15_000 }).catch(() => undefined)
115 if (watching !== timer) return // a newer push, or the session's end, came while gh was answering
116 const runs = got?.exitCode === 0 ? parse(got.stdout) : undefined
117 const mine = runs?.find(r => r.at >= began - SKEW)
118 const finished = mine?.status === 'completed'
119 if (runs === undefined || (!mine && elapsed >= WAIT) || (mine && !finished && elapsed >= CAP)) {
120 timer.cancel()
121 watching = undefined
122 if (owns) await $.hud.clear({ id: 'run' }).catch(() => undefined)
123 } else if (mine) {
124 const slot = slotOf(mine, branch)
125 if (JSON.stringify(slot) !== sent) sent = (await $.hud.push(slot).catch(() => undefined))?.ok ? JSON.stringify(slot) : ''
126 owns ||= sent !== ''
127 if (finished) {
128 timer.cancel()
129 if (watching === timer) watching = undefined
130 }
131 }
132 })
133 watching = timer
134 }
135 // A push starts fresh: the old result goes. (No branch to follow, a detached head, no repository or no git: nothing
136 // to say either.) A gh pr command never takes it away.
137 if (what === 'push') await $.hud.clear({ id: 'run' }).catch(() => undefined)
138 return ran
139 }).catch(($, e, next) => next(e)) // a fault here never costs the call
140}
141