SLOPSHOPPER

ci-watch

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…

newbandguardtoastpromptprocess
A shopper browsing a rack in a slop shop
README

ci-watch

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">

What it shows

  • ⟳ 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.

Failed scheduled workflows

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.

Limits

  • It needs gh, logged in to the repo's GitHub account.
  • It polls every 10 seconds and gives up after 60 minutes.
  • It reads the commit from origin. A push to another remote falls back to the local branch.
  • It sees pushes made through Claude's Bash tool. A push from another terminal is not seen.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install ci-watch@claude-code-mods

Restart Claude Code.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh ci-watch
Source 2 files
hooks/register.tsx 267 lines
1import { 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}
267
types/index.d.ts 15 lines
1export 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