Watches CI on pull requests the agent drafts or opens, in a live pane, the status line and a notification when it settles

A Claude Code mod that watches CI on pull requests the agent drafts or opens.
gh pr create (draft or not) and gh pr ready in any Bash call, plus GitHub MCP create_pull_request / ready-for-review tools, and reads the PR URL from the output.gh pr checks <url> every 15s (configurable) until every check settles.CI #42 ◐ 5/8 ✗1), and a one-line band above the prompt when the pane can't be placed (narrow terminal).onFinish in /config) it leaves Claude a note with the result (tell-claude), or starts a turn asking Claude to fix the failing checks (fix-failures).| Command | Does | |
|---|---|---|
/ci | open the pane | |
| `/ci watch <url \ | owner/repo#N>` | watch any PR by hand |
/ci clear | drop finished PRs | |
/ci stop | stop watching everything |
Requires an authenticated gh CLI. In a Claude Code terminal session:
/plugin install ci-watch --marketplace tidefox/claude-plugins
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
claude --plugin-dir plugins/ci-watch # run it from a checkout
claude plugin validate plugins/ci-watch
claude plugin test plugins/ci-watchhooks/register.tsx 562 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check, CheckBucket, Phase, WatchedPr } from '../types'
5
6const PANE = 'ci-watch'
7const TICK_MS = 1000
8const NO_CI_GRACE_MS = 5 * 60 * 1000
9const CONFIRM_MS = 10 * 1000
10const RERUN_HOLD_MS = 60 * 1000
11const MAX_PASSED_ROWS = 4
12const MAX_FAILED_ROWS = 8
13const MAX_RUNNING_ROWS = 5
14
15const prs = atom({ plugin: 'ci-watch', key: 'prs' } as const, [])
16const now = atom({ plugin: 'ci-watch', key: 'now' } as const, 0)
17const paneShown = atom({ plugin: 'ci-watch', key: 'paneShown' } as const, false)
18
19const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g
20const PR_REF = /([\w.-]+\/[\w.-]+)#(\d+)/
21const PR_COMMAND = /\bgh\s+pr\s+(create|ready)\b/
22const SPINNER = ['◐', '◓', '◑', '◒']
23
24const GLYPH: Record<CheckBucket, string> = {
25 pass: '✓',
26 fail: '✗',
27 pending: '●',
28 skipping: '○',
29 cancel: '⊘',
30}
31const COLOR: Record<CheckBucket, string> = {
32 pass: 'success',
33 fail: 'error',
34 pending: 'warning',
35 skipping: 'inactive',
36 cancel: 'inactive',
37}
38const ORDER: Record<CheckBucket, number> = { fail: 0, cancel: 1, pending: 2, pass: 3, skipping: 4 }
39const BADGE: Record<Phase, { label: string; color: string }> = {
40 waiting: { label: 'WAITING', color: 'inactive' },
41 running: { label: 'RUNNING', color: 'warning' },
42 passed: { label: 'PASSED', color: 'success' },
43 failed: { label: 'FAILED', color: 'error' },
44 'no-ci': { label: 'NO CI', color: 'inactive' },
45 error: { label: 'ERROR', color: 'error' },
46}
47
48export const isActive = (pr: WatchedPr) => pr.phase === 'waiting' || pr.phase === 'running' || pr.phase === 'error'
49
50/** The PR a command's output names: the last github.com PR URL, else `owner/repo#N`. */
51export function findPr(text: string): { url: string; repo: string; number: number } | null {
52 const urls = [...text.matchAll(PR_URL)]
53 const last = urls[urls.length - 1]
54 if (last?.[1] && last[2]) {
55 return { url: last[0], repo: last[1], number: Number(last[2]) }
56 }
57 const ref = PR_REF.exec(text)
58 if (ref?.[1] && ref[2]) {
59 return { url: `https://github.com/${ref[1]}/pull/${ref[2]}`, repo: ref[1], number: Number(ref[2]) }
60 }
61
62 return null
63}
64
65function toTime(iso: unknown): number | null {
66 if (typeof iso !== 'string') return null
67 const t = Date.parse(iso)
68 // gh reports a check that has not started as 0001-01-01.
69 return Number.isFinite(t) && t > 946684800000 ? t : null
70}
71
72export function parseChecks(stdout: string): Check[] {
73 const rows = JSON.parse(stdout) as Record<string, unknown>[]
74 return rows.map(row => ({
75 name: String(row.name ?? ''),
76 workflow: String(row.workflow ?? ''),
77 bucket: (['pass', 'fail', 'pending', 'skipping', 'cancel'].includes(String(row.bucket))
78 ? row.bucket
79 : 'pending') as CheckBucket,
80 link: String(row.link ?? ''),
81 startedAt: toTime(row.startedAt),
82 completedAt: toTime(row.completedAt),
83 }))
84}
85
86export function phaseOf(checks: Check[]): Phase {
87 if (checks.length === 0) return 'waiting'
88 if (checks.some(c => c.bucket === 'pending')) return 'running'
89 if (checks.some(c => c.bucket === 'fail' || c.bucket === 'cancel')) return 'failed'
90 return 'passed'
91}
92
93function count(checks: Check[], bucket: CheckBucket) {
94 return checks.filter(c => c.bucket === bucket).length
95}
96
97function duration(ms: number) {
98 const s = Math.max(0, Math.round(ms / 1000))
99 if (s < 60) return `${s}s`
100 const m = Math.floor(s / 60)
101 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
102 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
103}
104
105function checkTime(c: Check, t: number) {
106 if (c.startedAt === null) return ''
107 return duration((c.completedAt ?? t) - c.startedAt)
108}
109
110function summary(pr: WatchedPr, t: number) {
111 const done = pr.checks.filter(c => c.bucket !== 'pending').length
112 const fails = count(pr.checks, 'fail') + count(pr.checks, 'cancel')
113 const state =
114 pr.phase === 'running'
115 ? `${SPINNER[Math.floor(t / TICK_MS) % SPINNER.length]} ${done}/${pr.checks.length}${fails > 0 ? ` ✗${fails}` : ''}`
116 : pr.phase === 'passed'
117 ? `✓ passed`
118 : pr.phase === 'failed'
119 ? `✗ ${fails} failed`
120 : pr.phase === 'waiting'
121 ? '◌ waiting for checks'
122 : pr.phase === 'no-ci'
123 ? '○ no checks'
124 : '⚠ gh error'
125 return `CI #${pr.number} ${state}`
126}
127
128/** How long CI has run: first check start to last finish, else since the watch began. */
129function ciElapsed(pr: WatchedPr, t: number) {
130 const starts = pr.checks.map(c => c.startedAt).filter((v): v is number => v !== null)
131 if (starts.length === 0) return duration((pr.finishedAt ?? t) - pr.addedAt)
132 const ends = pr.checks.map(c => (c.bucket === 'pending' ? t : c.completedAt ?? t))
133 return duration(Math.max(...ends) - Math.min(...starts))
134}
135
136let pollMs = 15000
137let onFinish = 'notify'
138const inFlight = new Set<string>()
139let lastStatus: string | undefined
140
141function patch($: EngineInterface, url: string, fields: Partial<WatchedPr>) {
142 return update($, prs, list => list.map(pr => (pr.url === url ? { ...pr, ...fields } : pr)))
143}
144
145async function openPane($: EngineInterface) {
146 const opened = await $.ui.open({ id: PANE, title: 'CI' })
147 await update($, paneShown, () => opened.isPlaced)
148}
149
150async function watch($: EngineInterface, found: { url: string; repo: string; number: number }, isReady = false) {
151 const t = await $.clock.now()
152 const list = await read($, prs)
153 const known = list.find(pr => pr.url === found.url)
154 if (known) {
155 // Marked ready for review: workflows that skip drafts start now, so watch again.
156 if (isReady) {
157 await patch($, found.url, { isDraft: false, phase: 'waiting', addedAt: t, polledAt: null, finishedAt: null, error: null, settleKey: null })
158 }
159 } else {
160 const pr: WatchedPr = {
161 ...found,
162 title: '',
163 isDraft: false,
164 branch: '',
165 phase: 'waiting',
166 checks: [],
167 addedAt: t,
168 polledAt: null,
169 finishedAt: null,
170 error: null,
171 settleKey: null,
172 rerunAt: null,
173 }
174 await update($, prs, all => [pr, ...all.filter(p => p.url !== found.url)].slice(0, 8))
175 }
176 void openPane($)
177}
178
179async function announce($: EngineInterface, pr: WatchedPr) {
180 const failed = pr.checks.filter(c => c.bucket === 'fail' || c.bucket === 'cancel')
181 const text =
182 pr.phase === 'passed'
183 ? `✓ CI passed on #${pr.number} (${pr.checks.length} checks)`
184 : pr.phase === 'failed'
185 ? `✗ CI failed on #${pr.number}: ${failed.map(c => c.name).join(', ')}`
186 : `No CI checks reported on #${pr.number}`
187 $.ui.toast(text)
188 void $.ui.notify(text, { title: pr.title ? `CI · ${pr.title}` : 'CI' })
189
190 if (onFinish === 'notify' || pr.phase === 'no-ci') return
191 const detail = failed.map(c => `- ${c.workflow ? `${c.workflow} / ` : ''}${c.name}: ${c.link}`).join('\n')
192 const note = `[ci-watch] ${text}\nPR: ${pr.url}${detail ? `\nFailing checks:\n${detail}` : ''}`
193 if (onFinish === 'fix-failures' && pr.phase === 'failed') {
194 await $.prompt.submit({
195 text: `${note}\n\nInvestigate the failing CI checks on this PR (gh run view --log-failed), fix them, and push.`,
196 })
197 } else {
198 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
199 }
200}
201
202async function poll($: EngineInterface, pr: WatchedPr) {
203 if (inFlight.has(pr.url)) return
204 inFlight.add(pr.url)
205 try {
206 const t = await $.clock.now()
207 await patch($, pr.url, { polledAt: t })
208
209 if (pr.title === '') {
210 const view = await $.process.run(['gh', 'pr', 'view', pr.url, '--json', 'title,isDraft,headRefName'])
211 if (view.exitCode === 0) {
212 const meta = JSON.parse(view.stdout) as { title?: string; isDraft?: boolean; headRefName?: string }
213 await patch($, pr.url, { title: meta.title ?? '', isDraft: meta.isDraft === true, branch: meta.headRefName ?? '' })
214 }
215 }
216
217 // gh exits 8 while checks are pending and 1 when one failed: the JSON is on stdout either way.
218 const run = await $.process.run([
219 'gh', 'pr', 'checks', pr.url, '--json', 'bucket,name,workflow,link,startedAt,completedAt',
220 ])
221 const out = run.stdout.trim()
222 let next: Partial<WatchedPr>
223 if (out.startsWith('[')) {
224 const checks = parseChecks(out)
225 next = { checks, phase: phaseOf(checks), error: null }
226 } else if (/no checks reported/i.test(run.stderr)) {
227 next = { checks: [], phase: t - pr.addedAt > NO_CI_GRACE_MS ? 'no-ci' : 'waiting', error: null }
228 } else {
229 next = { phase: 'error', error: (run.stderr.trim() || `gh exited ${run.exitCode}`).split('\n')[0] ?? null }
230 }
231
232 // Every check reported so far may be done while later jobs have yet to register, or a
233 // re-run has yet to show: settle only when a second poll sees the same settled set.
234 if (next.phase === 'passed' || next.phase === 'failed') {
235 const key = (next.checks ?? []).map(c => `${c.name}:${c.bucket}`).sort().join('|')
236 const isHeld = pr.rerunAt !== null && t - pr.rerunAt < RERUN_HOLD_MS
237 if (isHeld || pr.settleKey !== key) {
238 next = { ...next, phase: 'running', settleKey: isHeld ? null : key, polledAt: t - pollMs + CONFIRM_MS }
239 }
240 } else if (next.phase === 'running') {
241 next = { ...next, settleKey: null, rerunAt: null }
242 }
243
244 const isDone = next.phase === 'passed' || next.phase === 'failed' || next.phase === 'no-ci'
245 if (isDone) next.finishedAt = t
246 await patch($, pr.url, next)
247 if (isDone) {
248 const settled = (await read($, prs)).find(p => p.url === pr.url)
249 // A failed notice must never reopen a settled PR, or it would announce on every poll.
250 if (settled) await announce($, settled).catch(() => undefined)
251 }
252 } catch (err) {
253 const message = err instanceof Error ? err.message : String(err)
254 await patch($, pr.url, { phase: 'error', error: /ENOENT|not found|cannot start/i.test(message) ? 'gh is not installed or not on PATH' : message })
255 } finally {
256 inFlight.delete(pr.url)
257 }
258}
259
260async function tick($: EngineInterface) {
261 const t = await $.clock.now()
262 const list = await read($, prs)
263 if (list.some(isActive)) await update($, now, () => t)
264 for (const pr of list) {
265 if (isActive(pr) && (pr.polledAt === null || t - pr.polledAt >= pollMs)) void poll($, pr)
266 }
267
268 const top = list.find(isActive) ?? list[0]
269 const status = top ? summary(top, t) : undefined
270 if (status !== lastStatus) {
271 lastStatus = status
272 $.ui.status(status)
273 }
274}
275
276async function rerunFailed($: EngineInterface, pr: WatchedPr) {
277 const runs = new Set<string>()
278 for (const c of pr.checks) {
279 const id = /\/actions\/runs\/(\d+)/.exec(c.link)?.[1]
280 if (id && (c.bucket === 'fail' || c.bucket === 'cancel')) runs.add(id)
281 }
282 if (runs.size === 0) {
283 $.ui.toast('No failed GitHub Actions runs to re-run')
284 return
285 }
286 for (const id of runs) {
287 const res = await $.process.run(['gh', 'run', 'rerun', id, '--failed', '-R', pr.repo])
288 if (res.exitCode !== 0) {
289 $.ui.toast(`Re-run of ${id} failed: ${res.stderr.trim().split('\n')[0] ?? ''}`)
290 return
291 }
292 }
293 $.ui.toast(`Re-running ${runs.size} failed workflow run${runs.size > 1 ? 's' : ''} on #${pr.number}`)
294 const t = await $.clock.now()
295 await patch($, pr.url, { phase: 'running', finishedAt: null, settleKey: null, rerunAt: t, polledAt: t - pollMs + 5000 })
296}
297
298export const register: Register = (on, options) => {
299 pollMs = Math.max(5, Number(options.pollSeconds ?? 15)) * 1000
300 onFinish = String(options.onFinish ?? 'notify')
301
302 on('session.start', async ($, e, next) => {
303 await $.command.register({
304 name: 'ci',
305 description: 'CI watcher: open the pane, or watch <PR url|owner/repo#N>, clear, stop',
306 argumentHint: '[watch <pr> | clear | stop]',
307 immediate: true,
308 })
309 $.clock.every(TICK_MS, () => void tick($))
310 if ((await read($, prs)).length > 0) void openPane($)
311
312 return next(e)
313 })
314
315 on('command.run', { command: 'ci' }, async ($, e) => {
316 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
317 if (verb === 'watch') {
318 const arg = rest.join(' ')
319 const found = findPr(arg)
320 if (!found) return { text: 'Usage: /ci watch <https://github.com/owner/repo/pull/N | owner/repo#N>' }
321 await watch($, found)
322 return { text: `Watching CI on ${found.repo}#${found.number}` }
323 }
324 if (verb === 'clear') {
325 await update($, prs, list => list.filter(isActive))
326 return { text: 'Cleared finished PRs.' }
327 }
328 if (verb === 'stop') {
329 await update($, prs, () => [])
330 lastStatus = undefined
331 $.ui.status(undefined)
332 await $.ui.close({ id: PANE })
333 return { text: 'Stopped watching all PRs.' }
334 }
335 await openPane($)
336 return { text: 'CI pane opened.' }
337 })
338
339 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
340 const match = PR_COMMAND.exec(e.command)
341 if (!match) return next(e)
342 const ran = await next(e)
343 const found = findPr(ran.text ?? '')
344 if (found) void watch($, found, match[1] === 'ready')
345
346 return ran
347 }).catch(($, e, next) => next(e))
348
349 // GitHub MCP servers' create / ready tools.
350 on('tool.call', async ($, e, next) => {
351 const name = String(e.tool)
352 if (!name.startsWith('mcp__') || !/(create_pull_request|ready_for_review)/i.test(name)) return next(e)
353 const ran = await next(e)
354 const found = ran.isError ? null : findPr(ran.text ?? '')
355 if (found) void watch($, found, /ready/i.test(name))
356
357 return ran
358 }).catch(($, e, next) => next(e))
359
360 on('ui.close', { id: PANE }, async ($, e, next) => {
361 await update($, paneShown, () => false)
362 return next(e)
363 }).catch(($, e, next) => next(e))
364
365 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
366 const { Box, Text, Button, Link } = $.ui.resolve(e)
367 const list = await read($, prs)
368 const t = Math.max(await read($, now), await $.clock.now())
369 const width = Math.max(24, e.props.bodyColumns)
370
371 if (list.length === 0) {
372 return (
373 <Box flexDirection="column" paddingX={1}>
374 <Text bold color="claude">CI watch</Text>
375 <Text dimColor>No pull requests watched yet.</Text>
376 <Text dimColor>They show up here when Claude runs gh pr create or gh pr ready,</Text>
377 <Text dimColor>or with /ci watch owner/repo#123.</Text>
378 </Box>
379 )
380 }
381
382 const spin = SPINNER[Math.floor(t / TICK_MS) % SPINNER.length] ?? '●'
383 const inner = width - 4
384
385 const bar = (pr: WatchedPr) => {
386 const room = Math.max(8, inner - 22)
387 const total = pr.checks.length
388 if (total === 0) return <Text dimColor>{'─'.repeat(room)}</Text>
389 const segments: [CheckBucket, number][] = (['pass', 'fail', 'cancel', 'pending', 'skipping'] as const).map(b => [b, count(pr.checks, b)])
390 let used = 0
391 const cells = segments.map(([b, n], i) => {
392 const cellsFor = i === segments.length - 1 ? room - used : Math.round((n / total) * room)
393 used += cellsFor
394 return [b, cellsFor] as const
395 })
396 return (
397 <Text>
398 {cells.filter(([, n]) => n > 0).map(([b, n]) => (
399 <Text color={COLOR[b]} dimColor={b === 'skipping' || b === 'cancel'}>
400 {(b === 'pending' ? '╍' : '━').repeat(n)}
401 </Text>
402 ))}
403 </Text>
404 )
405 }
406
407 const clip = (text: string, room: number) =>
408 text.length <= room ? text : room <= 1 ? '…' : `${text.slice(0, room - 1)}…`
409
410 const row = (pr: WatchedPr, c: Check, i: number) => {
411 const time = checkTime(c, t)
412 const room = Math.max(6, inner - 3 - time.length)
413 const flow = c.workflow && c.workflow !== c.name ? clip(c.workflow, Math.floor(room / 3)) : ''
414 const name = clip(c.name, room - (flow ? flow.length + 3 : 0))
415 return (
416 <Box key={`${pr.number}-${i}`} flexDirection="row">
417 <Box flexGrow={1} flexShrink={1}>
418 <Text wrap="truncate-end" dimColor={c.bucket === 'skipping'}>
419 <Text color={COLOR[c.bucket]}>{c.bucket === 'pending' ? spin : GLYPH[c.bucket]} </Text>
420 {flow ? <Text dimColor>{flow} › </Text> : ''}
421 {c.link ? <Link href={c.link}>{name}</Link> : name}
422 </Text>
423 </Box>
424 <Box flexShrink={0}>
425 <Text dimColor> {time}</Text>
426 </Box>
427 </Box>
428 )
429 }
430
431 const more = (glyph: string, color: string, n: number, what: string) =>
432 n > 0 ? (
433 <Text>
434 <Text color={color}>{glyph}</Text> <Text dimColor>{n} more {what}</Text>
435 </Text>
436 ) : (
437 ''
438 )
439
440 const card = (pr: WatchedPr) => {
441 const badge = BADGE[pr.phase]
442 const sorted = [...pr.checks].sort((a, b) => ORDER[a.bucket] - ORDER[b.bucket] || a.name.localeCompare(b.name))
443 const failing = sorted.filter(c => c.bucket === 'fail' || c.bucket === 'cancel')
444 const running = sorted.filter(c => c.bucket === 'pending')
445 const passed = sorted.filter(c => c.bucket === 'pass')
446 const shownFailing = failing.slice(0, MAX_FAILED_ROWS)
447 const shownRunning = running.slice(0, MAX_RUNNING_ROWS)
448 const shownPassed = passed.length <= MAX_PASSED_ROWS ? passed : []
449 const shown = [...shownFailing, ...shownRunning, ...shownPassed]
450 const skipped = count(pr.checks, 'skipping')
451 const pending = count(pr.checks, 'pending')
452 const hasFailures = count(pr.checks, 'fail') + count(pr.checks, 'cancel') > 0
453 const elapsed = ciElapsed(pr, t)
454
455 return (
456 <Box key={pr.url} flexDirection="column" borderStyle="round" borderColor={badge.color} paddingX={1}>
457 <Box flexDirection="row">
458 <Box flexGrow={1} flexShrink={1}>
459 <Text wrap="truncate-end">
460 <Text bold color="claude">#{pr.number}</Text>{' '}
461 <Link href={pr.url}>{pr.title || pr.repo}</Link>
462 </Text>
463 </Box>
464 {pr.isDraft ? <Text color="inactive"> DRAFT </Text> : ''}
465 <Text> </Text>
466 <Text bold color="inverseText" backgroundColor={badge.color}>
467 {' '}{pr.phase === 'running' ? `${spin} ` : ''}{badge.label}{' '}
468 </Text>
469 </Box>
470 <Text dimColor wrap="truncate-end">
471 {pr.repo}{pr.branch ? ` ⎇ ${pr.branch}` : ''} · ⏱ {elapsed}
472 </Text>
473 <Box flexDirection="row" marginTop={1}>
474 {bar(pr)}
475 <Text>
476 {' '}
477 <Text color="success">✓{count(pr.checks, 'pass')}</Text>{' '}
478 <Text color="error">✗{count(pr.checks, 'fail') + count(pr.checks, 'cancel')}</Text>{' '}
479 <Text color="warning">●{pending}</Text>
480 </Text>
481 </Box>
482
483 {pr.phase === 'waiting' && (
484 <Text dimColor>{spin} Waiting for checks to be reported…</Text>
485 )}
486 {pr.phase === 'no-ci' && <Text dimColor>No checks were reported for this PR.</Text>}
487 {pr.error && <Text color="error" wrap="truncate-end">⚠ {pr.error}</Text>}
488
489 {shown.length > 0 && (
490 <Box flexDirection="column" marginTop={1}>
491 {shownFailing.map((c, i) => row(pr, c, i))}
492 {more('✗', 'error', failing.length - shownFailing.length, 'failed')}
493 {shownRunning.map((c, i) => row(pr, c, MAX_FAILED_ROWS + i))}
494 {more(spin, 'warning', running.length - shownRunning.length, 'running')}
495 {shownPassed.map((c, i) => row(pr, c, MAX_FAILED_ROWS + MAX_RUNNING_ROWS + i))}
496 </Box>
497 )}
498 {passed.length > MAX_PASSED_ROWS && (
499 <Text>
500 <Text color="success">✓</Text> <Text dimColor>{passed.length} checks passed</Text>
501 </Text>
502 )}
503 {skipped > 0 && <Text dimColor>○ {skipped} skipped</Text>}
504
505 <Box flexDirection="row" gap={2} marginTop={1}>
506 {hasFailures && pr.phase !== 'running' && (
507 <Button key={`rerun:${pr.url}`} label="re-run failed" variant="primary" onPress={() => rerunFailed($, pr)} />
508 )}
509 <Button
510 key={`refresh:${pr.url}`}
511 label="refresh"
512 onPress={() => patch($, pr.url, { phase: isActive(pr) ? pr.phase : 'running', finishedAt: null, settleKey: null, polledAt: null })}
513 />
514 <Button
515 key={`unwatch:${pr.url}`}
516 label="unwatch"
517 dimColor
518 onPress={() => update($, prs, all => all.filter(p => p.url !== pr.url))}
519 />
520 </Box>
521 </Box>
522 )
523 }
524
525 const active = list.filter(isActive).length
526
527 return (
528 <Box flexDirection="column">
529 <Box flexDirection="row" paddingX={1}>
530 <Box flexGrow={1}>
531 <Text bold color="claude">⬡ CI watch</Text>
532 </Box>
533 <Text dimColor>
534 {active > 0 ? `${active} running · every ${pollMs / 1000}s` : 'all settled'}
535 </Text>
536 </Box>
537 {list.map(card)}
538 </Box>
539 )
540 })
541
542 // A one-line band for when the pane waits undrawn (a narrow terminal it opened on unasked).
543 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
544 const list = await read($, prs)
545 const pr = list.find(isActive)
546 if (!pr || e.props.hasSurvey || (await read($, paneShown))) return next(e)
547
548 const { Box, Text, Button } = $.ui.resolve(e)
549 const t = Math.max(await read($, now), await $.clock.now())
550 const fails = count(pr.checks, 'fail')
551
552 return (
553 <Box flexDirection="row" gap={1}>
554 <Text color={BADGE[pr.phase].color}>{summary(pr, t)}</Text>
555 <Text dimColor wrap="truncate-end">{pr.title}</Text>
556 {fails > 0 && <Text color="error">{fails} failing</Text>}
557 <Button key="ci-open" label="details" plain hotkey="c" onPress={() => openPane($)} />
558 </Box>
559 )
560 })
561}
562types/index.d.ts 38 lines1export type CheckBucket = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
2
3export type Check = {
4 name: string
5 workflow: string
6 bucket: CheckBucket
7 link: string
8 startedAt: number | null
9 completedAt: number | null
10}
11
12export type Phase = 'waiting' | 'running' | 'passed' | 'failed' | 'no-ci' | 'error'
13
14export type WatchedPr = {
15 url: string
16 repo: string
17 number: number
18 title: string
19 isDraft: boolean
20 branch: string
21 phase: Phase
22 checks: Check[]
23 addedAt: number
24 polledAt: number | null
25 finishedAt: number | null
26 error: string | null
27 /** The check set of the last all-settled poll: CI settles when the next poll repeats it. */
28 settleKey: string | null
29 /** When failed runs were re-run: no settling until a check is seen pending, or a minute passes. */
30 rerunAt: number | null
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'ci-watch': { prs: WatchedPr[]; now: number; paneShown: boolean }
36 }
37}
38