A pane that shows where the current branch is in the CI/CD pipeline, from local dev to prod smoke tests

Two mods for Claude Code. They work in the terminal and in the Code tab of the Claude desktop app.
| Mod | What it shows |
|---|---|
ci-pipeline | A pane with the CI/CD stage of the branch you work on |
my-actions | A box above the prompt with the actions and questions the agent left for you |
Early access. Mods use the Claude Code function-hooks API. This API changes between releases. I tested these mods on Claude Code 2.1.288.
In Claude Code, add this repo as a plugin marketplace:
/plugin marketplace add alamine42/claude-mods
Then install the mods you want:
/plugin install ci-pipeline@claude-mods
/plugin install my-actions@claude-mods
To try a mod without installing it, clone this repo and start Claude Code with the mod folder:
claude --plugin-dir ./plugins/my-actions
<!-- TODO: screenshot -->
A pane that shows where the current branch is in 12 stages:
| Code | Staging | Production |
|---|---|---|
| Local dev | Deploying to Staging | Deploying to Prod |
| PR open | Staging Deployed | Prod Deployed |
| PR checks running | Smoke Testing Staging | Smoke Testing Prod |
| PR merged | Staging Smoke Tested | Prod Smoke Tested |
Each stage has a mark: ✓ done, ● running, ✗ failed, ◌ the next stage it waits for, · not yet reached. The status line shows the current stage. A toast tells you when the stage changes.
Commands
/pipeline opens the pane./pipeline 123 follows PR #123./pipeline branch follows the current branch again.r to refresh.How it finds each stage
git and gh pr view.workflow / job against a regex for each stage.Options
| Option | Default | |
|---|---|---|
deployStaging | `deploy.*stag\ | stag.*deploy` |
smokeStaging | `smoke.*stag\ | stag.*smoke` |
deployProd | `deploy.*prod\ | prod.*deploy` |
smokeProd | `smoke.*prod\ | prod.*smoke` |
pollSeconds | 20 |
Change them in the Claude Code config menu, or under pluginConfigs.ci-pipeline in your settings.
Needs: git and the GitHub CLI, logged in.
Limits
main, the pane follows main. Use /pipeline <number> to keep a PR in view.<!-- TODO: screenshot -->
When you come back to a long session, the asks for you are easy to miss in the text. This mod lists them above the prompt, in two boxes:
▸): a task only you can do, such as a check in the app or a test by hand. It shows the exact command when there is one.↻, dim): a task the agent asked you to do but could do itself. Tell the agent to do it, or do it yourself.?): a question the agent waits on. A request for your go-ahead ("Shall I push?") is a question too.Each item has a Done button. When the box has focus, keys 1–9 press it. Clear empties one box. The boxes sit side by side when the window is wide enough. Long items wrap, and a long list scrolls. A task shows in one box only. The status line shows a count, for example Your move: 2 actions, 1 question.
How it works. When a reply ends, the mod sends the reply, your last message, and the open list to a small model. The model returns the new list. It adds new asks. It removes the asks that you answered or that the agent did. When you send a message, the mod also checks it at once and clears the questions it answers, so they do not wait for the reply to end. When the mod loads or a session resumes, it reads the last few messages.
Commands
/my-actions builds the list again from the whole session./my-actions clear empties the list.Options
| Option | Default |
|---|---|
model | haiku |
Cost and privacy. The mod makes one model call per reply, on your own Claude account. Each call sends the reply text and your last message to that model.
Limits
/my-actions to correct the list.MIT
hooks/register.tsx 301 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mark, Pipeline, Stage } from '../types'
5
6const PANE = 'ci-pipeline'
7const pipeline = atom({ plugin: 'ci-pipeline', key: 'pipeline' } as const, null)
8const pinned = atom({ plugin: 'ci-pipeline', key: 'pinned' } as const, null)
9
10const NAMES = [
11 'Local dev',
12 'PR open',
13 'PR checks running',
14 'PR merged',
15 'Deploying to Staging',
16 'Staging Deployed',
17 'Smoke Testing Staging',
18 'Staging Smoke Tested',
19 'Deploying to Prod',
20 'Prod Deployed',
21 'Smoke Testing Prod',
22 'Prod Smoke Tested',
23]
24
25const GROUPS = [
26 { title: 'Code', from: 0, to: 4 },
27 { title: 'Staging', from: 4, to: 8 },
28 { title: 'Production', from: 8, to: 12 },
29]
30
31const GLYPH: Record<Mark, string> = {
32 done: '✓',
33 running: '●',
34 failed: '✗',
35 waiting: '◌',
36 pending: '·',
37}
38
39const COLOR: Record<Mark, string | undefined> = {
40 done: 'green',
41 running: 'yellow',
42 failed: 'red',
43 waiting: 'cyan',
44 pending: undefined,
45}
46
47type Job = { label: string; status: string; conclusion: string }
48type Result = 'running' | 'failed' | 'done' | null
49
50// A GitHub job or check state, folded to one result.
51const resultOf = (status: string, conclusion: string): Result => {
52 const s = status.toLowerCase()
53 const c = conclusion.toLowerCase()
54 if (s && s !== 'completed') return 'running'
55 if (['failure', 'cancelled', 'timed_out', 'action_required', 'startup_failure'].includes(c)) return 'failed'
56 if (c === 'skipped' || c === 'neutral') return null
57 return 'done'
58}
59
60// Many jobs for one stage: any failure wins, then any running job.
61const summarise = (jobs: Job[]): Result => {
62 const results = jobs.map(j => resultOf(j.status, j.conclusion)).filter(r => r !== null)
63 if (results.length === 0) return null
64 if (results.includes('failed')) return 'failed'
65 if (results.includes('running')) return 'running'
66 return 'done'
67}
68
69const toRegex = (source: unknown, fallback: string) => {
70 try {
71 return new RegExp(typeof source === 'string' && source ? source : fallback, 'i')
72 } catch {
73 return new RegExp(fallback, 'i')
74 }
75}
76
77let match = {
78 smokeStaging: /smoke.*stag|stag.*smoke/i,
79 smokeProd: /smoke.*prod|prod.*smoke/i,
80 deployStaging: /deploy.*stag|stag.*deploy/i,
81 deployProd: /deploy.*prod|prod.*deploy/i,
82}
83
84// Jobs of completed runs do not change, so keep them for the module's life.
85const doneRuns = new Map<number, Job[]>()
86let isBusy = false
87
88async function run($: EngineInterface, argv: string[]) {
89 const out = await $.process.run(argv, { timeoutMs: 20000 })
90 return { ok: out.exitCode === 0, text: out.stdout.trim(), err: out.stderr.trim() }
91}
92
93async function jobsFor($: EngineInterface, sha: string): Promise<Job[]> {
94 const list = await run($, [
95 'gh', 'run', 'list', '--commit', sha, '--limit', '30',
96 '--json', 'databaseId,workflowName,status,conclusion',
97 ])
98 if (!list.ok) return []
99 const runs = JSON.parse(list.text || '[]') as {
100 databaseId: number; workflowName: string; status: string; conclusion: string
101 }[]
102 const all: Job[] = []
103 for (const r of runs) {
104 const kept = doneRuns.get(r.databaseId)
105 if (kept) {
106 all.push(...kept)
107 continue
108 }
109 const view = await run($, ['gh', 'run', 'view', String(r.databaseId), '--json', 'jobs'])
110 if (!view.ok) continue
111 const { jobs } = JSON.parse(view.text) as { jobs: { name: string; status: string; conclusion: string }[] }
112 const mapped = jobs.map(j => ({
113 label: `${r.workflowName} / ${j.name}`,
114 status: j.status ?? '',
115 conclusion: j.conclusion ?? '',
116 }))
117 if (r.status === 'completed') doneRuns.set(r.databaseId, mapped)
118 all.push(...mapped)
119 }
120 return all
121}
122
123async function build($: EngineInterface): Promise<Pipeline> {
124 const stages: Stage[] = NAMES.map(name => ({ name, mark: 'pending' as Mark }))
125 const updatedAt = await $.clock.now()
126 const head = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
127 if (!head.ok) return { branch: null, pr: null, stages, error: 'Not a git repository', updatedAt }
128 const branch = head.text
129
130 const pin = await read($, pinned)
131 const view = await run($, [
132 'gh', 'pr', 'view', ...(pin ? [String(pin)] : []),
133 '--json', 'number,title,url,state,mergedAt,mergeCommit,statusCheckRollup',
134 ])
135
136 if (!view.ok) {
137 const dirty = await run($, ['git', 'status', '--porcelain'])
138 const ahead = await run($, ['git', 'rev-list', '--count', '@{u}..HEAD'])
139 const changed = dirty.ok && dirty.text ? dirty.text.split('\n').length : 0
140 const notes = [
141 changed ? `${changed} changed file${changed === 1 ? '' : 's'}` : 'clean',
142 ahead.ok ? `${ahead.text} unpushed` : 'no upstream',
143 ]
144 stages[0] = { ...stages[0], mark: 'running', note: notes.join(', ') }
145 const isNoPr = /no pull requests? found/i.test(view.err)
146 return { branch, pr: null, stages, error: isNoPr ? undefined : view.err.split('\n')[0], updatedAt }
147 }
148
149 const pr = JSON.parse(view.text) as {
150 number: number; title: string; url: string; state: string; mergedAt: string | null
151 mergeCommit: { oid: string } | null
152 statusCheckRollup: { status?: string; conclusion?: string; state?: string }[] | null
153 }
154 stages[0] = { ...stages[0], mark: 'done' }
155 stages[1] = { ...stages[1], mark: 'done', note: `#${pr.number}` }
156
157 // CheckRun has status/conclusion; StatusContext has state only.
158 const checks = (pr.statusCheckRollup ?? []).map(c =>
159 c.state !== undefined
160 ? resultOf(c.state === 'PENDING' || c.state === 'EXPECTED' ? 'in_progress' : 'completed', c.state === 'SUCCESS' ? 'success' : 'failure')
161 : resultOf(c.status ?? '', c.conclusion ?? ''),
162 )
163 const total = checks.filter(c => c !== null).length
164 const running = checks.filter(c => c === 'running').length
165 const failed = checks.filter(c => c === 'failed').length
166 if (running) stages[2] = { ...stages[2], mark: 'running', note: `${total - running}/${total} done` }
167 else if (failed) stages[2] = { ...stages[2], mark: 'failed', note: `${failed} of ${total} failed` }
168 else stages[2] = { ...stages[2], mark: 'done', note: total ? `${total} passed` : 'no checks' }
169
170 if (pr.state === 'CLOSED') stages[3] = { ...stages[3], mark: 'failed', note: 'closed without merge' }
171 if (pr.state === 'MERGED') {
172 stages[3] = { ...stages[3], mark: 'done' }
173 const jobs = pr.mergeCommit ? await jobsFor($, pr.mergeCommit.oid) : []
174 const pick = (re: RegExp, not?: RegExp) => jobs.filter(j => re.test(j.label) && !(not && not.test(j.label)))
175 const envs = [
176 { at: 4, deploy: pick(match.deployStaging, match.smokeStaging), smoke: pick(match.smokeStaging) },
177 { at: 8, deploy: pick(match.deployProd, match.smokeProd), smoke: pick(match.smokeProd) },
178 ]
179 for (const env of envs) {
180 for (const [offset, group] of [[0, env.deploy], [2, env.smoke]] as const) {
181 const result = summarise(group)
182 const i = env.at + offset
183 if (result === 'running') stages[i] = { ...stages[i], mark: 'running', note: group[0]?.label }
184 if (result === 'failed') stages[i] = { ...stages[i], mark: 'failed', note: group.find(j => resultOf(j.status, j.conclusion) === 'failed')?.label }
185 if (result === 'done') {
186 stages[i] = { ...stages[i], mark: 'done' }
187 stages[i + 1] = { ...stages[i + 1], mark: 'done' }
188 }
189 }
190 }
191 }
192
193 // The first open stage after an unbroken run of done stages is the one we wait on.
194 const next = stages.findIndex(s => s.mark !== 'done')
195 if (next >= 0 && stages[next].mark === 'pending') stages[next] = { ...stages[next], mark: 'waiting' }
196
197 return { branch, pr: { number: pr.number, title: pr.title, url: pr.url }, stages, updatedAt }
198}
199
200const current = (p: Pipeline) =>
201 p.stages.find(s => s.mark === 'failed' || s.mark === 'running' || s.mark === 'waiting') ??
202 [...p.stages].reverse().find(s => s.mark === 'done')
203
204async function refresh($: EngineInterface) {
205 if (isBusy) return
206 isBusy = true
207 try {
208 const before = await read($, pipeline)
209 const after = await build($)
210 await update($, pipeline, () => after)
211 const now = current(after)
212 if (now && after.branch) $.ui.status(`CI ${GLYPH[now.mark]} ${now.name}`)
213 const was = before && current(before)
214 const isSameTarget = before?.branch === after.branch && before?.pr?.number === after.pr?.number
215 if (now && was && isSameTarget && (was.name !== now.name || was.mark !== now.mark)) {
216 $.ui.toast(`CI: ${now.name}${now.mark === 'failed' ? ' failed' : ''}`)
217 }
218 } catch (err) {
219 $.ui.status(`CI ? ${String(err).slice(0, 60)}`)
220 } finally {
221 isBusy = false
222 }
223}
224
225export const register: Register = (on, options) => {
226 match = {
227 smokeStaging: toRegex(options.smokeStaging, 'smoke.*stag|stag.*smoke'),
228 smokeProd: toRegex(options.smokeProd, 'smoke.*prod|prod.*smoke'),
229 deployStaging: toRegex(options.deployStaging, 'deploy.*stag|stag.*deploy'),
230 deployProd: toRegex(options.deployProd, 'deploy.*prod|prod.*deploy'),
231 }
232 const pollMs = Math.max(5, Number(options.pollSeconds) || 20) * 1000
233
234 on('session.start', async ($, e, next) => {
235 await $.command.register({
236 name: 'pipeline',
237 description: 'Show the CI/CD pipeline pane. Args: a PR number to pin, "branch" to follow the current branch',
238 })
239 void $.ui.open({ id: PANE, title: 'Pipeline' })
240 void refresh($)
241 $.clock.every(pollMs, () => void refresh($))
242
243 return next(e)
244 })
245
246 on('command.run', { command: 'pipeline' }, async ($, e) => {
247 const arg = e.args.trim()
248 if (/^\d+$/.test(arg)) await update($, pinned, () => Number(arg))
249 if (arg === 'branch') await update($, pinned, () => null)
250 await $.ui.open({ id: PANE, title: 'Pipeline' })
251 void refresh($)
252 const pin = await read($, pinned)
253
254 return { text: pin ? `Pipeline pane open, following PR #${pin}.` : 'Pipeline pane open, following the current branch.' }
255 })
256
257 // Pushes and PRs often happen inside a turn, so look again when it ends.
258 on('turn.complete', async ($, e, next) => {
259 void refresh($)
260
261 return next(e)
262 })
263
264 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265 const { Box, Text, Button } = $.ui.resolve(e)
266 const p = await read($, pipeline)
267 const pin = await read($, pinned)
268
269 if (!p) return <Text dimColor>Reading pipeline status…</Text>
270
271 const time = new Date(p.updatedAt).toLocaleTimeString()
272 const target = p.pr ? `PR #${p.pr.number} ${p.pr.title}` : 'no PR yet'
273
274 return (
275 <Box flexDirection="column">
276 <Text bold wrap="truncate-end">{p.branch ?? '—'}{pin ? ' (pinned)' : ''}</Text>
277 <Text dimColor wrap="truncate-end">{target}</Text>
278 {p.error && <Text color="red" wrap="truncate-end">{p.error}</Text>}
279 {GROUPS.map(g => (
280 <Box flexDirection="column" marginTop={1}>
281 <Text dimColor>{g.title}</Text>
282 {p.stages.slice(g.from, g.to).map(s => (
283 <Box>
284 <Text color={COLOR[s.mark]} dimColor={s.mark === 'pending'}>{GLYPH[s.mark]} </Text>
285 <Text bold={s.mark === 'running' || s.mark === 'waiting' || s.mark === 'failed'} dimColor={s.mark === 'pending'} wrap="truncate-end">
286 {s.name}
287 {s.note ? <Text dimColor> {s.note}</Text> : null}
288 </Text>
289 </Box>
290 ))}
291 </Box>
292 ))}
293 <Box marginTop={1}>
294 <Text dimColor>Updated {time} </Text>
295 <Button key="refresh" label="Refresh" hotkey="r" plain onPress={() => void refresh($)} />
296 </Box>
297 </Box>
298 )
299 })
300}
301types/index.d.ts 18 lines1export type Mark = 'done' | 'running' | 'failed' | 'waiting' | 'pending'
2
3export type Stage = { name: string; mark: Mark; note?: string }
4
5export type Pipeline = {
6 branch: string | null
7 pr: { number: number; title: string; url: string } | null
8 stages: Stage[]
9 error?: string
10 updatedAt: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'ci-pipeline': { pipeline: Pipeline | null; pinned: number | null }
16 }
17}
18