Watches GitHub Actions in a band above the prompt after Claude pushes, opens a PR or pushes a tag: a progress bar per run, then pass or fail; optionally flags…

Shows GitHub Actions runs until they finish, without leaving Claude Code. After Claude pushes a branch, opens a PR or pushes a tag, a band above the prompt shows each workflow run with a progress bar, then whether it passed, also as a toast.
<img src="../docs/ci-watch.png" alt="A CI run with its progress bar, then the passed result" width="556">
⟳ push main · CI ████████░░░░░░░░░░░░ 1m10s / ~2m45s: one row per running workflow. The bar compares the time so far with the workflow's last successful run, and stays short of full until the run ends.✓ push main: CI passed or ✗ push main: CI failure: the result, in green or red, also as a toast. It stays until you send your next message.push docs: no workflow triggered: no run started within 90 seconds.A push and the PR opened on that branch share one row, and it waits for both sets of runs. A push to a branch with an open PR also waits for its pull_request runs. A tag push waits for the runs on that tag, not the branch CI that already ran on the same commit.
Only the repo Claude Code was started in is watched, and only when it has a .github/workflows folder. A push from cd <other repo> && git push or git -C <other repo> is left alone.
Turn on Flag failed scheduled workflows in /config to check scheduled runs at start. Each workflow whose latest scheduled run in the last 14 days failed gets a red row with its link. Dismiss hides those runs for this repo; a later failure shows again.
This is off by default, because it makes a few gh calls at every start.
gh, logged in to the repo's GitHub account.origin. A push to another remote falls back to the local branch.claude plugin marketplace add arasovic/claude-code-mods
claude plugin install ci-watch@claude-code-mods
Restart Claude Code.
claude plugin validate .
claude plugin test .
../typecheck.sh ci-watchhooks/register.tsx 267 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CiWatchBroken, CiWatchItem, CiWatchSpec } from '../types'
5
6const POLL_MS = 10_000
7const NO_RUN_MS = 90_000
8const MAX_WATCH_MS = 60 * 60_000
9// ponytail: covers daily and weekly schedules; a monthly one whose last run failed is missed.
10const LOOKBACK_MS = 14 * 24 * 60 * 60_000
11const BAR_WIDTH = 20
12const BAD = ['failure', 'timed_out', 'startup_failure']
13
14type Run = { workflowName: string; workflowDatabaseId: number; event: string; headBranch: string; status: string; conclusion: string; startedAt: string }
15
16const watches = atom({ plugin: 'ci-watch', key: 'watches' } as const, [] as CiWatchItem[])
17const broken = atom({ plugin: 'ci-watch', key: 'broken' } as const, [] as CiWatchBroken[])
18// One watch per ref and commit: a branch push and the PR opened on it merge into one row instead of two.
19const active = new Map<string, CiWatchSpec>()
20let repoUrl: string | undefined
21
22// Bash calls start in the session cwd, so a push from elsewhere arrives as `cd <repo> && git push` or `git -C <repo>`.
23// ponytail: only a leading cd or git -C; pushd and subshells fall back to the session cwd.
24export const repoDir = (command: string, home: string, root: string) => {
25 const m = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|[^\s;&|]+)\s*(&&|;)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
26 if (!m?.[1]) return root
27 const dir = m[1].replace(/^["']|["']$/g, '')
28 if (dir === '~' || dir.startsWith('~/')) return home + dir.slice(1)
29 return dir.startsWith('/') ? dir : `${root}/${dir}`
30}
31
32// Only the session's repo is watched; a push or PR in another checkout is someone else's. Worktrees live under the root.
33export const isInside = (dir: string, root: string) => !dir.split('/').includes('..') && (dir === root || dir.startsWith(`${root}/`))
34
35// The engine's gitOperation.push covers branches only; tag pushes are read from git's own output.
36export const pushedTag = (output: string) => output.match(/\[new tag\]\s+\S+\s*->\s*(\S+)/)?.[1]
37
38const runName = (r: Pick<Run, 'workflowName' | 'event'>) => (r.event === 'pull_request' ? `${r.workflowName} (PR)` : r.workflowName)
39
40export const summarize = (label: string, runs: Pick<Run, 'workflowName' | 'event' | 'conclusion'>[]) => {
41 const failed = runs.filter(r => r.conclusion !== 'success' && r.conclusion !== 'skipped' && r.conclusion !== 'neutral')
42 return failed.length
43 ? `✗ ${label}: ${failed.map(r => `${runName(r)} ${r.conclusion}`).join(', ')}`
44 : `✓ ${label}: ${runs.map(runName).join(', ')} passed`
45}
46
47// Progress is elapsed time against the workflow's last successful run: monotonic, unlike step counts,
48// which drop when a dependent job starts and reveals its steps. Never shows full until the run completes.
49export const bar = (elapsedMs: number, expectedMs: number | null) => {
50 const filled = expectedMs ? Math.min(BAR_WIDTH - 1, Math.round((elapsedMs / expectedMs) * BAR_WIDTH)) : 0
51 return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
52}
53
54export const duration = (ms: number) => `${Math.floor(ms / 60_000)}m${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}s`
55
56async function gh($: EngineInterface, cwd: string, args: string[]) {
57 const r = await $.process.run(['gh', ...args], { cwd, timeoutMs: 20_000 }).catch(() => undefined)
58 return r?.exitCode === 0 ? r.stdout.trim() : undefined
59}
60
61async function git($: EngineInterface, cwd: string, args: string[]) {
62 const r = await $.process.run(['git', ...args], { cwd }).catch(() => undefined)
63 return r?.exitCode === 0 ? r.stdout.trim() : undefined
64}
65
66async function lastSuccessMs($: EngineInterface, cwd: string, workflowId: number) {
67 const out = await gh($, cwd, ['run', 'list', '-w', String(workflowId), '--status', 'success', '-L', '1', '--json', 'startedAt,updatedAt'])
68 const [r] = out ? (JSON.parse(out) as { startedAt: string; updatedAt: string }[]) : []
69 return r ? Date.parse(r.updatedAt) - Date.parse(r.startedAt) : null
70}
71
72async function setItem($: EngineInterface, item: CiWatchItem) {
73 await update($, watches, list => [...list.filter(w => w.id !== item.id), item])
74}
75
76const copy = (w: CiWatchSpec): CiWatchSpec => ({ ...w, labels: [...w.labels], required: { ...w.required } })
77
78// A watch ends once every event it waits for has runs and none is pending. An event that never produced a run
79// stops being waited for after NO_RUN_MS, so a push CI that finishes before `gh pr create` does not end the watch,
80// and a push to a branch whose CI runs only on pull_request still ends.
81async function watch($: EngineInterface, spec: CiWatchSpec, isResume = false) {
82 const id = `${spec.ref}@${spec.sha.slice(0, 7)}`
83 const existing = active.get(id)
84 if (existing) {
85 Object.assign(existing.required, spec.required)
86 for (const l of spec.labels) if (!existing.labels.includes(l)) existing.labels.push(l)
87 return
88 }
89
90 const w = copy(spec)
91 active.set(id, w)
92 const expected = new Map<number, number | null>()
93 let isBusy = false
94 if (!isResume) await setItem($, { id, label: w.labels.join(', '), runs: [], spec: copy(w) })
95
96 const finish = async (result: string) => {
97 active.delete(id)
98 timer.cancel()
99 await setItem($, { id, label: w.labels.join(', '), runs: [], result, isFailed: result.startsWith('✗') })
100 $.ui.toast(result)
101 }
102
103 const timer = $.clock.every(POLL_MS, async () => {
104 if (isBusy || active.get(id) !== w) return
105 isBusy = true
106 try {
107 const out = await gh($, w.cwd, ['run', 'list', '--commit', w.sha, '--json', 'workflowName,workflowDatabaseId,event,headBranch,status,conclusion,startedAt'])
108 const runs = (out ? (JSON.parse(out) as Run[]) : []).filter(r => r.headBranch === w.ref && r.event in w.required)
109 const now = await $.clock.now()
110 const label = w.labels.join(', ')
111 const pending = runs.filter(r => r.status !== 'completed')
112 const isAwaiting = Object.entries(w.required).some(([ev, since]) => !runs.some(r => r.event === ev) && now - since <= NO_RUN_MS)
113 if (!isAwaiting && !runs.length) {
114 await finish(`${label}: no workflow triggered`)
115 } else if (!isAwaiting && !pending.length) {
116 await finish(summarize(label, runs))
117 } else if (now - w.startedAt > MAX_WATCH_MS) {
118 await finish(`✗ ${label}: still running after 60 min`)
119 } else {
120 for (const r of pending) {
121 if (!expected.has(r.workflowDatabaseId)) expected.set(r.workflowDatabaseId, await lastSuccessMs($, w.cwd, r.workflowDatabaseId))
122 }
123 const rows = pending.map(r => ({
124 name: runName(r),
125 elapsedMs: Math.max(0, now - Date.parse(r.startedAt)) || 0,
126 expectedMs: expected.get(r.workflowDatabaseId) ?? null,
127 }))
128 await setItem($, { id, label, runs: rows, spec: copy(w) })
129 }
130 } finally {
131 isBusy = false
132 }
133 })
134}
135
136type BashResult = { stdout: string; stderr: string; gitOperation?: { push?: { branch: string }; pr?: { number: number; url?: string; action: string } } }
137
138async function track($: EngineInterface, command: string, result: BashResult) {
139 const op = result.gitOperation
140 const tag = /\bgit\b[^;&|]*\bpush\b/.test(command) ? pushedTag(`${result.stdout}\n${result.stderr}`) : undefined
141 const pr = op?.pr?.action === 'created' ? op.pr : undefined
142 if (!op?.push && !pr && !tag) return
143
144 const root = await $.session.cwd()
145 const cwd = repoDir(command, (await $.env.get('HOME')) ?? '', root)
146 if (!isInside(cwd, root)) return
147 // A repo without workflows would only end in "no workflow triggered".
148 // ponytail: looks at the session cwd; a session started in a subfolder of the repo is not watched.
149 if (!(await $.fs.exists(`${root}/.github/workflows`))) return
150 const startedAt = await $.clock.now()
151 const spec = (sha: string, ref: string, label: string, events: string[]): CiWatchSpec => ({
152 cwd: root, sha, ref, labels: [label], required: Object.fromEntries(events.map(ev => [ev, startedAt])), startedAt,
153 })
154
155 if (op?.push) {
156 const { branch } = op.push
157 // ponytail: assumes the remote is origin; another remote falls back to the local branch's sha.
158 const sha = (await git($, cwd, ['rev-parse', `origin/${branch}`])) ?? (await git($, cwd, ['rev-parse', branch]))
159 // A push to a branch with an open PR also runs its pull_request workflows.
160 if (sha) await watch($, spec(sha, branch, `push ${branch}`, ['push', 'pull_request']))
161 }
162 if (tag) {
163 const sha = await git($, cwd, ['rev-parse', `${tag}^{commit}`])
164 if (sha) await watch($, spec(sha, tag, `tag ${tag}`, ['push']))
165 }
166 if (pr) {
167 repoUrl ??= await gh($, root, ['repo', 'view', '--json', 'url', '-q', '.url'])
168 if (pr.url && (!repoUrl || !pr.url.startsWith(`${repoUrl}/`))) return
169 const out = await gh($, root, ['pr', 'view', String(pr.number), '--json', 'headRefOid,headRefName'])
170 const head = out ? (JSON.parse(out) as { headRefOid: string; headRefName: string }) : undefined
171 if (head) await watch($, spec(head.headRefOid, head.headRefName, `PR #${pr.number}`, ['pull_request']))
172 }
173}
174
175// A reload drops the old module's timers; an unfinished row resumes from its spec, one without a spec would stay frozen and is dropped.
176async function resumeWatches($: EngineInterface) {
177 const list = await read($, watches)
178 if (list.some(w => !w.result && !w.spec)) await update($, watches, l => l.filter(w => w.result || w.spec))
179 for (const w of list) {
180 if (!w.result && w.spec) await watch($, w.spec, true)
181 }
182}
183
184// Lists workflows whose latest scheduled run failed; a workflow without a schedule trigger never appears.
185async function scanSchedules($: EngineInterface, cwd: string) {
186 const root = await git($, cwd, ['rev-parse', '--show-toplevel'])
187 if (!root) return
188 const since = new Date((await $.clock.now()) - LOOKBACK_MS).toISOString().slice(0, 10)
189 const out = await gh($, root, ['run', 'list', '--event', 'schedule', '--created', `>=${since}`, '-L', '100', '--json', 'workflowDatabaseId,conclusion'])
190 if (!out) return
191 const ids = [...new Set((JSON.parse(out) as { workflowDatabaseId: number; conclusion: string }[]).filter(r => BAD.includes(r.conclusion)).map(r => r.workflowDatabaseId))]
192 const dismissed = ((await $.store.get(`dismissed:${root}`)) ?? []) as string[]
193 const found: CiWatchBroken[] = []
194 for (const id of ids) {
195 const last = await gh($, root, ['run', 'list', '-w', String(id), '--event', 'schedule', '-L', '1', '--json', 'workflowName,conclusion,createdAt,url'])
196 const [run] = last ? (JSON.parse(last) as { workflowName: string; conclusion: string; createdAt: string; url: string }[]) : []
197 if (run && BAD.includes(run.conclusion) && !dismissed.includes(run.url)) {
198 found.push({ workflow: run.workflowName, at: run.createdAt.slice(0, 10), url: run.url })
199 }
200 }
201 await update($, broken, () => found)
202}
203
204async function dismiss($: EngineInterface) {
205 const root = await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel'])
206 const shown = await read($, broken)
207 if (root) {
208 const prev = ((await $.store.get(`dismissed:${root}`)) ?? []) as string[]
209 await $.store.set(`dismissed:${root}`, [...prev, ...shown.map(b => b.url)].slice(-50))
210 }
211 await update($, broken, () => [])
212}
213
214export const register: Register = (on, options) => {
215 const isScanOn = options.scheduled === true
216
217 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
218 const ran = await next(e)
219 if (ran.deny === undefined && !ran.isError && ran.result) {
220 void track($, e.command, ran.result as BashResult).catch(() => undefined)
221 }
222 return ran
223 })
224
225 on('session.start', async ($, e, next) => {
226 const started = await next(e)
227 void resumeWatches($).catch(() => undefined)
228 if (isScanOn) void scanSchedules($, e.cwd).catch(() => undefined)
229 else await update($, broken, () => [])
230 return started
231 })
232
233 // A finished watch stays in the band until the person sends their next prompt.
234 on('prompt.submit', async ($, e, next) => {
235 if ((await read($, watches)).some(w => w.result)) await update($, watches, list => list.filter(w => !w.result))
236 return next(e)
237 })
238
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 const items = await read($, watches)
241 const list = await read($, broken)
242 if (e.props.hasSurvey || (!items.length && !list.length)) return next(e)
243
244 const { Box, Button, Text } = $.ui.resolve(e)
245 const rows = items.flatMap(w => {
246 if (w.result) return [<Text key={w.id} color={w.isFailed ? 'red' : w.result.startsWith('✓') ? 'green' : undefined}>{w.result}</Text>]
247 if (!w.runs.length) return [<Text key={w.id} dimColor>{`⟳ ${w.label} · waiting for workflows…`}</Text>]
248 return w.runs.map((r, i) => (
249 <Text key={`${w.id}:${i}`}>
250 {`⟳ ${w.label} · ${r.name} ${bar(r.elapsedMs, r.expectedMs)} ${duration(r.elapsedMs)}${r.expectedMs ? ` / ~${duration(r.expectedMs)}` : ''}`}
251 </Text>
252 ))
253 })
254 for (const b of list) {
255 rows.push(<Text key={b.url} color="red">{`✗ scheduled "${b.workflow}" failed (${b.at}) ${b.url}`}</Text>)
256 }
257 if (list.length) rows.push(<Button key="dismiss" label="Dismiss" onPress={() => dismiss($)} />)
258
259 return (
260 <Box flexDirection="column">
261 {await next(e)}
262 {rows}
263 </Box>
264 )
265 })
266}
267types/index.d.ts 15 lines1export type CiWatchRun = { name: string; elapsedMs: number; expectedMs: number | null }
2
3// What a watch waits for: each event in `required` since when, so a reload can resume the poll from the stored row.
4export type CiWatchSpec = { cwd: string; sha: string; ref: string; labels: string[]; required: Record<string, number>; startedAt: number }
5
6export type CiWatchItem = { id: string; label: string; runs: CiWatchRun[]; spec?: CiWatchSpec; result?: string; isFailed?: boolean }
7
8export type CiWatchBroken = { workflow: string; at: string; url: string }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'ci-watch': { watches: CiWatchItem[]; broken: CiWatchBroken[] }
13 }
14}
15