After every git push, follows the GitHub Actions runs (via gh) until they finish; with an open pull request (also after gh pr create, or /ci pr <nr>) all its…

After every git push, follows the GitHub Actions runs (via gh) until they finish; with an open pull request (also after gh pr create, or /ci pr <nr>) all its checks, other CI included, and whether it is ready to merge. On red it shows the failed step log.
Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install ci-watch --marketplace LupusMalusDeviant/claude-mods
| Command | What it does | |
|---|---|---|
/ci | state of the watched runs | |
| `/ci pr <nr\ | url>` | watch a pull request |
/ci stop | stop watching |
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
intervalSec | 20 | How often gh is asked for the state of the runs. |
onRed | both | pane: open the log pane. prompt: put an analysis request into the prompt (not sent). both. |
sound | off | on: a short sound when the runs are green or red (Windows: system sounds). |
notify | red | When a watched run ends, through your notification channel (preferredNotifChannel): off, only red, or always. Claude Code notifies at the end of a turn; CI usually ends later. |
prOnDesktop | app | app: in the desktop app a new PR is watched by the app itself (CI bar, auto-fix), so nothing is reported twice; pushes are still watched here. box: CI Watch follows the PR as in the terminal. |
language | en | Language of the texts this mod shows and writes. |
MIT, see LICENSE.
hooks/entry.tsx 30 lines1// Entry of the standalone CI Watch. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/ci-watch/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11 openBand(e)
12 const rest = await next(e)
13 const parts = takeBand(e)
14 if (e.props.hasSurvey || parts.length === 0) return rest
15 const { Box, Text } = $.ui.resolve(e)
16 return (
17 <Box flexDirection="column">
18 {rest}
19 {parts.map(p => (
20 <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21 <Box flexShrink={1}>{p.line as any}</Box>
22 {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{' '}</Text>, a])}
23 </Box>
24 ))}
25 </Box>
26 )
27 })
28 ;(inner as any)(on, options)
29}
30mods/ci-watch/hooks/register.tsx 453 lines1// CI watch: "A push you don't check on doesn't count as done."
2// After every successful `git push` (Bash tool) it polls the GitHub Actions runs for the
3// pushed commit via gh until all are done. A band above the prompt shows the status;
4// on red it fetches `gh run view --log-failed`, opens a window with the log and/or puts
5// an analysis request into the prompt (nothing is sent by itself). /ci shows the status.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, Timer } from 'claude-code'
8
9import type { CiRun, CiWatch } from '../types'
10import { offer as offerBand } from '../../settings/hooks/band'
11import type { BandPart } from '../../settings/hooks/band'
12
13const watch = atom({ plugin: 'ci-watch', key: 'watch' } as const, null as CiWatch | null)
14const PANE = 'ci-watch'
15const NO_RUNS_AFTER_MS = 120_000 // how long GitHub may take to create a run
16const GIVE_UP_AFTER_MS = 3 * 60 * 60_000
17const LOG_LINES = 200
18
19export const PUSH = /(?:^|[;&|(]\s*)git\s+(?:-C\s+(\S+)\s+)?push\b/
20
21// Settings and timers at module level: the validator requires $ to be passed only to
22// top-level functions.
23let intervalMs = 20_000
24let onRed = 'both'
25let withSound = false
26let notifyOn = 'red'
27let prOnDesktop = 'app'
28let onDesktop = false
29
30export const PR_CREATE = /(?:^|[;&|(]\s*)gh\s+pr\s+create\b/
31export const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+)/
32
33/** owner/repo of a pull request's URL, for gh -R; '' for anything else. Pure. */
34export function repoOf(url: string): string {
35 return /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/\d+/.exec(url)?.[1] ?? ''
36}
37
38// gh finds a pull request by its URL from any folder; by its number only inside its repository
39// (a PR made from another folder or a worktree is watched from the session's folder).
40const prRef = (pr: { number: number; url: string }) => pr.url || String(pr.number)
41// Display language (options.language: de | en)
42let lang: 'de' | 'en' = 'en'
43const t = (de: string, en: string) => (lang === 'en' ? en : de)
44let timer: Timer | null = null
45
46function stop() {
47 timer?.cancel()
48 timer = null
49}
50
51function start($: EngineInterface) {
52 stop()
53 timer = $.clock.every(intervalMs, () => void poll($).catch(() => {}))
54 void poll($).catch(() => {})
55}
56
57async function poll($: EngineInterface) {
58 const w = await read($, watch)
59 if (!w || (w.phase !== 'warte' && w.phase !== 'laeuft')) return stop()
60 const now = await $.clock.now()
61 const fetched = w.pr ? await prChecks($, w) : await commitRuns($, w)
62 if (typeof fetched === 'string') {
63 await set($, x => ({ ...x, phase: 'fehler', message: fetched, endedAt: now }))
64 return stop()
65 }
66 const runs = fetched
67 if (runs.length === 0) {
68 if (now - w.startedAt > NO_RUNS_AFTER_MS) {
69 await set($, x => ({ ...x, phase: 'keine', message: t('Kein Actions-Lauf zu diesem Commit.', 'No Actions run for this commit.'), endedAt: now }))
70 stop()
71 }
72 return
73 }
74 const open = runs.filter(x => x.status !== 'completed')
75 if (open.length > 0) {
76 await set($, x => ({ ...x, phase: 'laeuft', runs }))
77 if (now - w.startedAt > GIVE_UP_AFTER_MS) {
78 await set($, x => ({ ...x, phase: 'fehler', message: t('Nach 3 Stunden nicht fertig; Überwachung beendet.', 'Not finished after 3 hours; stopped watching.'), endedAt: now }))
79 stop()
80 }
81 return
82 }
83 const bad = runs.find(x => !['success', 'skipped', 'neutral'].includes(x.conclusion))
84 stop()
85 if (!bad) {
86 const merge = w.pr ? await mergeState($, w) : ''
87 await set($, x => ({ ...x, phase: 'gruen', runs, endedAt: now, message: '', pr: x.pr ? { ...x.pr, merge } : null }))
88 $.ui.toast(
89 w.pr
90 ? t(`PR #${w.pr.number}: alle ${runs.length} Checks grün${merge ? ` · ${merge}` : ''}`, `PR #${w.pr.number}: all ${runs.length} checks green${merge ? ` · ${merge}` : ''}`)
91 : t(`CI grün: ${runs.length} Lauf/Läufe für ${w.sha.slice(0, 7)}`, `CI green: ${runs.length} run(s) for ${w.sha.slice(0, 7)}`),
92 )
93 void sound($, 'green')
94 void notify($, 'green', w.pr ? `PR #${w.pr.number}: ${t('alle Checks grün', 'all checks green')}` : `${w.branch} ${w.sha.slice(0, 7)}: ${t('alle Läufe grün', 'all runs green')}`)
95 return
96 }
97 const log = bad.id ? await $.process.run(['gh', 'run', 'view', String(bad.id), '--log-failed', ...(w.pr && repoOf(w.pr.url) ? ['-R', repoOf(w.pr.url)] : [])], { cwd: w.cwd, timeoutMs: 60_000 }).catch(() => null) : null
98 const tail = (log?.stdout ?? '').split('\n').slice(-LOG_LINES).join('\n').trim()
99 const noLog = bad.id
100 ? t('(kein Log erhalten)', '(no log received)')
101 : t(`(Check außerhalb von GitHub Actions: kein Log über gh. Details: ${bad.url})`, `(check outside GitHub Actions: no log through gh. Details: ${bad.url})`)
102 await set($, x => ({ ...x, phase: 'rot', runs, endedAt: now, failed: { id: bad.id, name: bad.name, url: bad.url, log: tail || noLog } }))
103 void sound($, 'red')
104 void notify($, 'red', `${bad.name} (${bad.conclusion}) · ${w.pr ? `PR #${w.pr.number}` : w.branch}`)
105 $.ui.toast(t(`CI rot: ${bad.name} (${bad.conclusion}). Analysieren, bevor irgendetwas anderes passiert.`, `CI red: ${bad.name} (${bad.conclusion}). Analyse it before anything else happens.`))
106 if (onRed === 'pane' || onRed === 'both') void $.ui.open({ id: PANE, title: `${t('CI rot', 'CI red')} · ${bad.name}`, focus: true, closeOnEscape: true }).catch(() => {})
107 if (onRed === 'prompt' || onRed === 'both') void $.prompt.fill({ text: analyse(w, bad), mode: 'replace' }).catch(() => {})
108}
109
110async function startAfterPush($: EngineInterface, dashC: string | undefined, command: string) {
111 if (await begin($, dashC, command)) start($)
112}
113
114function ghError(stderr: string): string {
115 return /not found|nicht gefunden|ENOENT|cannot find/i.test(stderr)
116 ? t('gh ist nicht installiert.', 'gh is not installed.')
117 : /auth|login/i.test(stderr)
118 ? t('gh ist nicht angemeldet (gh auth login).', 'gh is not signed in (gh auth login).')
119 : stderr.trim().split('\n')[0] || t('gh-Fehler', 'gh error')
120}
121
122// The Actions runs of the pushed commit; a message when gh fails.
123async function commitRuns($: EngineInterface, w: CiWatch): Promise<CiRun[] | string> {
124 const r = await $.process
125 .run(['gh', 'run', 'list', '--commit', w.sha, '--json', 'databaseId,name,workflowName,status,conclusion,url', '--limit', '30'], { cwd: w.cwd, timeoutMs: 30_000 })
126 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
127 if (r.exitCode !== 0) return ghError(r.stderr)
128 return (JSON.parse(r.stdout || '[]') as Array<Record<string, unknown>>).map(x => ({
129 id: Number(x.databaseId),
130 name: String(x.workflowName || x.name || 'Lauf'),
131 status: String(x.status ?? ''),
132 conclusion: String(x.conclusion ?? ''),
133 url: String(x.url ?? ''),
134 }))
135}
136
137/** gh pr checks --json: bucket pass|fail|pending|skipping|cancel; exit 8 while pending, 1 on failure. */
138export function parseChecks(stdout: string): CiRun[] {
139 const list = JSON.parse(stdout || '[]') as Array<Record<string, unknown>>
140 return list.map(x => {
141 const bucket = String(x.bucket ?? '')
142 const link = String(x.link ?? '')
143 const run = /\/actions\/runs\/(\d+)/.exec(link)
144 return {
145 id: run ? Number(run[1]) : 0,
146 name: x.workflow ? `${String(x.workflow)} / ${String(x.name ?? '')}` : String(x.name ?? 'Check'),
147 status: bucket === 'pending' ? 'in_progress' : 'completed',
148 conclusion: bucket === 'pass' ? 'success' : bucket === 'skipping' ? 'skipped' : bucket === 'fail' ? 'failure' : bucket === 'cancel' ? 'cancelled' : bucket,
149 url: link,
150 }
151 })
152}
153
154// The checks of a pull request: Actions and every other check (other CI, bots).
155async function prChecks($: EngineInterface, w: CiWatch): Promise<CiRun[] | string> {
156 const r = await $.process
157 .run(['gh', 'pr', 'checks', prRef(w.pr!), '--json', 'name,state,bucket,link,workflow'], { cwd: w.cwd, timeoutMs: 30_000 })
158 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
159 if (r.stdout.trim().startsWith('[')) return parseChecks(r.stdout)
160 if (/no checks reported/i.test(r.stderr)) return []
161 return ghError(r.stderr)
162}
163
164// After green: can the PR be merged? (mergeStateStatus and reviewDecision of gh pr view)
165async function mergeState($: EngineInterface, w: CiWatch): Promise<string> {
166 const r = await $.process
167 .run(['gh', 'pr', 'view', prRef(w.pr!), '--json', 'mergeStateStatus,reviewDecision,isDraft,state'], { cwd: w.cwd, timeoutMs: 30_000 })
168 .catch(() => null)
169 if (!r || r.exitCode !== 0) return ''
170 return mergeText(JSON.parse(r.stdout || '{}'))
171}
172
173export function mergeText(v: { mergeStateStatus?: string; reviewDecision?: string; isDraft?: boolean; state?: string }): string {
174 if (v.state && v.state !== 'OPEN') return v.state === 'MERGED' ? t('schon gemergt', 'already merged') : t('geschlossen', 'closed')
175 if (v.isDraft) return t('noch Entwurf', 'still a draft')
176 if (v.reviewDecision === 'CHANGES_REQUESTED') return t('Änderungen angefordert', 'changes requested')
177 if (v.reviewDecision === 'REVIEW_REQUIRED') return t('wartet auf Review', 'waiting for review')
178 switch (v.mergeStateStatus) {
179 case 'CLEAN':
180 case 'HAS_HOOKS':
181 return t('bereit zum Mergen', 'ready to merge')
182 case 'DIRTY':
183 return t('Merge-Konflikte', 'merge conflicts')
184 case 'BEHIND':
185 return t('Branch ist hinter der Basis', 'branch is behind its base')
186 case 'BLOCKED':
187 return t('blockiert (Regeln des Repos)', 'blocked (repo rules)')
188 case 'UNSTABLE':
189 return t('nicht alle Pflicht-Checks grün', 'not all required checks green')
190 default:
191 return ''
192 }
193}
194
195// Starts watching a pull request: its number or URL, in the folder cwd.
196async function beginPr($: EngineInterface, ref: string, cwd: string): Promise<boolean> {
197 // No ref: the pull request of the current branch.
198 const r = await $.process.run(['gh', 'pr', 'view', ...(ref ? [ref] : []), '--json', 'number,url,title,headRefName,headRefOid,state'], { cwd, timeoutMs: 30_000 }).catch(() => null)
199 if (!r || r.exitCode !== 0) return false
200 const v = JSON.parse(r.stdout || '{}') as { number?: number; url?: string; title?: string; headRefName?: string; headRefOid?: string; state?: string }
201 if (!v.number || v.state !== 'OPEN') return false
202 const now = await $.clock.now()
203 await update($, watch, (): CiWatch => ({
204 sha: String(v.headRefOid ?? ''),
205 branch: String(v.headRefName ?? '?'),
206 cwd,
207 startedAt: now,
208 endedAt: 0,
209 phase: 'warte',
210 runs: [],
211 failed: null,
212 message: '',
213 isDismissed: false,
214 pr: { number: Number(v.number), url: String(v.url ?? ''), title: String(v.title ?? ''), merge: '' },
215 }))
216 return true
217}
218
219async function startPr($: EngineInterface, ref: string, cwd: string): Promise<boolean> {
220 const ok = await beginPr($, ref, cwd)
221 if (ok) start($)
222 return ok
223}
224
225// A system notification at the end (option notify: off | red | always), through the person's own
226// notification channel. Claude Code notifies when a turn ends; a CI run usually ends later.
227async function notify($: EngineInterface, kind: 'green' | 'red', text: string) {
228 if (notifyOn === 'off' || (notifyOn === 'red' && kind === 'green')) return
229 await $.ui.notify(text, { title: kind === 'red' ? t('CI rot', 'CI red') : t('CI grün', 'CI green') }).catch(() => undefined)
230}
231
232// A short sound at the end (option): Windows system sounds through PowerShell, elsewhere the mod's own clip.
233async function sound($: EngineInterface, kind: 'green' | 'red') {
234 if (!withSound) return
235 if ((await $.env.get('OS')) === 'Windows_NT') {
236 const s = kind === 'green' ? 'Asterisk' : 'Hand'
237 await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `[System.Media.SystemSounds]::${s}.Play(); Start-Sleep -Milliseconds 900`], { timeoutMs: 10_000 }).catch(() => {})
238 } else {
239 await $.audio.play({ asset: `mods/ci-watch/sounds/${kind}.wav` }).catch(() => {})
240 }
241}
242
243export const register: Register = (on, options) => {
244 // Band manager: hand this mod’s display to the Mod Box (it draws, orders and places it).
245 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
246 const rest = await next(e)
247 if (!e.props.hasSurvey) {
248 const part = await band($, e).catch(() => null)
249 if (part) offerBand(e, part)
250 }
251 return rest
252 })
253
254 intervalMs = Math.max(5, Number(options.intervalSec) || 20) * 1000
255 onRed = String(options.onRed || 'both')
256 withSound = options.sound === 'on'
257 notifyOn = String(options.notify || 'red')
258 prOnDesktop = String(options.prOnDesktop || 'app')
259 lang = options.language === 'de' ? 'de' : 'en'
260
261 on('session.start', {}, async ($, e, next) => {
262 const r = await next(e)
263 onDesktop = (e as { surface?: string | null }).surface === 'desktop'
264 await $.command.register({
265 name: 'ci',
266 description: t('CI-Wächter: Stand der Läufe zum letzten Push oder PR', 'CI Watch: state of the runs for the last push or PR'),
267 argumentHint: '[pr <nr|url> | stop]',
268 })
269 // Watching continues after a reload.
270 const w = await read($, watch)
271 if (w && (w.phase === 'warte' || w.phase === 'laeuft')) start($)
272 return r
273 })
274
275 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
276 const result = await next(e)
277 if (('deny' in result && result.deny !== undefined) || result.isError) return result
278 const cmd = String(e.command ?? '')
279 // A new pull request: its URL stands in the output of gh pr create.
280 if (PR_CREATE.test(cmd)) {
281 const url = PR_URL.exec(String(result.text ?? ''))?.[0]
282 // The desktop app binds a new PR to the session and watches its checks itself (CI bar, auto-fix).
283 if (url && onDesktop && prOnDesktop === 'app') $.ui.toast(t('PR-Checks überwacht die Desktop-App (CI-Leiste); /ci pr zeigt sie hier.', 'The desktop app watches the PR checks (CI bar); /ci pr shows them here.'))
284 else if (url) void startPr($, url, await $.session.cwd()).catch(() => {})
285 return result
286 }
287 const m = PUSH.exec(cmd)
288 if (!m) return result
289 if (/\bpush\b[^;&|]*(?:--dry-run|\s-n\b)/.test(cmd)) return result
290 void startAfterPush($, m[1], cmd).catch(() => {})
291 return result
292 })
293
294 on('command.run', { command: 'ci' }, async ($, e) => {
295 const args = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
296 if (args[0] === 'stop') {
297 stop()
298 await update($, watch, () => null)
299 return { text: t('CI-Wächter: Beobachtung beendet.', 'CI Watch: stopped watching.') }
300 }
301 if (args[0] === 'pr') {
302 const ref = args[1] ?? ''
303 const ok = ref ? await startPr($, ref, await $.session.cwd()) : await startPr($, '', await $.session.cwd())
304 return {
305 text: ok
306 ? t(`CI-Wächter: beobachte PR ${ref || '(Branch)'}.`, `CI Watch: watching PR ${ref || '(branch)'}.`)
307 : t('CI-Wächter: kein offener PR gefunden (/ci pr <Nummer oder URL>).', 'CI Watch: no open PR found (/ci pr <number or URL>).'),
308 }
309 }
310 const w = await read($, watch)
311 if (!w) return { text: t('CI-Wächter: noch kein Push in dieser Sitzung. /ci pr <Nummer> beobachtet einen PR.', 'CI Watch: no push in this session yet. /ci pr <number> watches a PR.') }
312 if (w.phase === 'rot') await $.ui.open({ id: PANE, title: `${t('CI rot', 'CI red')} · ${w.failed?.name ?? ''}`, focus: true, closeOnEscape: true })
313 await update($, watch, x => (x ? { ...x, isDismissed: false } : x))
314 return { text: `${t('CI-Wächter', 'CI Watch')}: ${summary(w)}` }
315 })
316
317 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
318 const { Box, Text, Button, Code } = $.ui.resolve(e) as any
319 const w = await read($, watch)
320 const f = w?.failed
321 if (!w || !f) return <Text dimColor>{t('Kein roter Lauf.', 'No red run.')}</Text>
322 const rows = Math.max(5, (e.viewport?.rows ?? 30) - 8)
323 return (
324 <Box flexDirection="column" paddingX={1}>
325 <Text>
326 <Text color="red" bold>{`✘ ${f.name}`}</Text>
327 <Text dimColor>{w.pr ? ` · PR #${w.pr.number} ${w.pr.title}` : ` · ${w.branch}@${w.sha.slice(0, 7)}`}</Text>
328 </Text>
329 <Text dimColor>{f.url}</Text>
330 <Box marginTop={1} flexDirection="column">
331 {Code ? <Code source={f.log.split('\n').slice(-rows).join('\n')} wrap="truncate-end" /> : <Text>{f.log.split('\n').slice(-rows).join('\n')}</Text>}
332 </Box>
333 <Box flexDirection="row" marginTop={1}>
334 <Button key="ci-p-analyse" label={t('Analysieren lassen', 'Have it analysed')} variant="primary" autoFocus onPress={() => void $.prompt.fill({ text: analyse(w, f), mode: 'replace' }).then(() => $.ui.close({ id: PANE }))} />
335 <Text>{' '}</Text>
336 <Button key="ci-p-kopieren" label={t('Log kopieren', 'Copy log')} onPress={(press: { surface?: string }) => void $.ui.copy({ text: f.log, surface: press.surface as any })} />
337 <Text>{' '}</Text>
338 <Button key="ci-p-zu" label={t('Schließen', 'Close')} role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
339 </Box>
340 </Box>
341 )
342 })
343}
344
345async function set($: EngineInterface, fn: (w: CiWatch) => CiWatch) {
346 await update($, watch, w => (w ? fn(w) : w))
347}
348
349async function begin($: EngineInterface, dashC: string | undefined, command: string): Promise<boolean> {
350 const base = await $.session.cwd()
351 const cd = /(?:^|[;&|]\s*)cd\s+("[^"]+"|'[^']+'|\S+)\s*&&[^;|]*git\s+(?:-C\s+\S+\s+)?push/.exec(command)?.[1]?.replace(/^["']|["']$/g, '')
352 const dir = dashC ?? cd
353 const cwd = dir ? (/^([a-zA-Z]:[\\/]|\/)/.test(dir) ? dir : `${base}/${dir}`) : base
354 const head = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd }).catch(() => null)
355 if (!head || head.exitCode !== 0) return false
356 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd }).catch(() => null)
357 // Has the branch an open pull request? Then its checks count (also other CI and bots).
358 if (await beginPr($, branch?.stdout.trim() || '', cwd)) return true
359 const now = await $.clock.now()
360 await update($, watch, (): CiWatch => ({
361 sha: head.stdout.trim(),
362 branch: branch?.stdout.trim() || '?',
363 cwd,
364 startedAt: now,
365 endedAt: 0,
366 phase: 'warte',
367 runs: [],
368 failed: null,
369 message: '',
370 isDismissed: false,
371 pr: null,
372 }))
373 return true
374}
375
376export function summary(w: CiWatch): string {
377 const ref = w.pr ? `PR #${w.pr.number}` : `${w.branch}@${w.sha.slice(0, 7)}`
378 const took = w.endedAt && w.startedAt ? ` in ${fmt(w.endedAt - w.startedAt)}` : ''
379 switch (w.phase) {
380 case 'warte':
381 return `${ref}: ${t('warte auf GitHub Actions …', 'waiting for GitHub Actions …')}`
382 case 'laeuft': {
383 const done = w.runs.filter(r => r.status === 'completed').length
384 return `${ref}: ${t(`${w.runs.length - done} laufen, ${done} fertig`, `${w.runs.length - done} running, ${done} done`)} (${w.runs.map(r => r.name).join(', ')})`
385 }
386 case 'gruen':
387 return w.pr
388 ? `${ref}: ${t(`${w.runs.length} Checks grün`, `${w.runs.length} checks green`)}${took}${w.pr.merge ? ` · ${w.pr.merge}` : ''}`
389 : `${ref}: ${t(`grün, ${w.runs.length} Lauf/Läufe`, `green, ${w.runs.length} run(s)`)}${took}`
390 case 'rot':
391 return `${ref}: ${t('rot in', 'red in')} ${w.failed?.name ?? '?'}${took}`
392 case 'keine':
393 return `${ref}: ${w.message}`
394 case 'fehler':
395 return `${ref}: ${w.message}`
396 }
397}
398
399function fmt(ms: number): string {
400 const s = Math.round(ms / 1000)
401 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
402}
403
404// The request Claude gets is English; only the answer follows the language setting.
405export function analyse(w: CiWatch, f: { id: number; name: string; url: string }): string {
406 const what = w.pr ? `PR #${w.pr.number} (${w.pr.url})` : `${w.branch}@${w.sha.slice(0, 7)}`
407 const answerIn = lang === 'en' ? '' : ' Answer in German.'
408 if (!f.id) {
409 // A check outside GitHub Actions: no log through gh, only its link.
410 return `CI is red for ${what}: check "${f.name}" (${f.url}) failed; it does not run in GitHub Actions. Open its details, name the cause and whether it lies in the code, in the setup or in something external, then propose the fix before anything else happens.${answerIn}`
411 }
412 return (
413 `CI is red: run "${f.name}" for ${w.branch}@${w.sha.slice(0, 7)} (${f.url}). ` +
414 `Read the logs of the failed step (gh run view ${f.id} --log-failed), name the cause ` +
415 'and say whether it lies in the code, in the fixture or in something external. If external: should the test withstand it? ' +
416 `Then propose the fix, before anything else happens.${answerIn}`
417 )
418}
419
420// For the Mod Box band manager (instead of an AbovePrompt hook of its own).
421export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
422 const w = await read($, watch)
423 if (!w || w.isDismissed) return null
424 const { Text, Button } = $.ui.resolve(e)
425 const color = w.phase === 'gruen' ? 'green' : w.phase === 'rot' ? 'red' : w.phase === 'fehler' ? 'yellow' : 'cyan'
426 const icon = w.phase === 'gruen' ? '✔' : w.phase === 'rot' ? '✘' : w.phase === 'fehler' || w.phase === 'keine' ? '·' : '⏳'
427 const isDone = w.phase !== 'warte' && w.phase !== 'laeuft'
428 const actions: unknown[] = []
429 if (w.phase === 'rot') {
430 actions.push(<Button key="ci-log" label="Log" plain onPress={() => void $.ui.open({ id: PANE, title: `${t('CI rot', 'CI red')} · ${w.failed?.name ?? ''}`, focus: true, closeOnEscape: true })} />)
431 if (w.failed) actions.push(<Button key="ci-analyse" label={t('Analysieren', 'Analyse')} plain onPress={() => void $.prompt.fill({ text: analyse(w, w.failed!), mode: 'replace' })} />)
432 }
433 if (isDone) actions.push(<Button key="ci-weg" label="×" plain onPress={() => void update($, watch, x => (x ? { ...x, isDismissed: true } : x))} />)
434 return {
435 id: 'ciWatch',
436 urgent: w.phase === 'rot' || w.phase === 'fehler',
437 short: (
438 <Text>
439 <Text color={color} bold>{`CI ${icon}`}</Text>
440 <Text dimColor>{` ${w.pr ? `#${w.pr.number} ` : ''}${w.phase === 'laeuft' ? `${w.runs.filter(r => r.status === 'completed').length}/${w.runs.length}` : w.phase === 'gruen' ? (w.pr?.merge || t('grün', 'green')) : w.phase === 'warte' ? '…' : w.pr ? '' : w.branch}`}</Text>
441 </Text>
442 ),
443 line: (
444 <Text>
445 <Text color={color} bold>{`CI ${icon} `}</Text>
446 <Text>{summary(w)}</Text>
447 </Text>
448 ),
449 actions,
450 status: `CI ${icon} ${summary(w)}`,
451 }
452}
453mods/settings/hooks/band.ts 44 lines1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6 /** The mod's switch key, e.g. "cacheMeter". */
7 id: string
8 /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9 urgent: boolean
10 /** One short element for the compact line. */
11 short: unknown
12 /** The full element for its own line. */
13 line: unknown
14 /** Buttons that belong to the part. */
15 actions: unknown[]
16 /** Plain text for the status line. */
17 status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23 return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28 parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33 const list = parts.get(keyOf(e))
34 if (list) list.push(part)
35 else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40 const list = parts.get(keyOf(e)) ?? []
41 parts.delete(keyOf(e))
42 return list
43}
44mods/ci-watch/types/contract.d.ts 41 lines1export type CiRun = {
2 /** Actions run id; 0 for a check outside GitHub Actions (no log through gh). */
3 id: number
4 name: string
5 status: string
6 conclusion: string
7 url: string
8}
9
10export type CiPhase = 'warte' | 'laeuft' | 'gruen' | 'rot' | 'keine' | 'fehler'
11
12/** The pull request being watched, when it is one. */
13export type CiPr = {
14 number: number
15 url: string
16 title: string
17 /** After green: ready to merge, waiting for a review, conflicts … (empty while running). */
18 merge: string
19}
20
21export type CiWatch = {
22 sha: string
23 branch: string
24 cwd: string
25 startedAt: number
26 endedAt: number
27 phase: CiPhase
28 runs: CiRun[]
29 /** On red: the first failed run or check and the end of its log. */
30 failed: { id: number; name: string; url: string; log: string } | null
31 message: string
32 isDismissed: boolean
33 pr: CiPr | null
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'ci-watch': { watch: CiWatch | null }
39 }
40}
41