SLOPSHOPPER

ci-watch

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-watch
│ ┃ ci-watch ✕ › fix the failing auth test and add an audit log call │ ┃ No red run. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ci │ ⎿ ci-watch: CI Watch: feat/auth-refresh@a1b2c3d: waiting for GitHu │ │ ⟨Claude Code's own drawing⟩ CI ⏳ feat/auth-refresh@a1b2c3d: waiting for GitHub Actions … ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ CI ⏳ feat/auth-refresh@a1b2c3d: waiting for GitHub Actions …
Pane · ci-watch
No red run.
README

CI Watch

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.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install ci-watch --marketplace LupusMalusDeviant/claude-mods

Commands

CommandWhat it does
/cistate of the watched runs
`/ci pr <nr\url>`watch a pull request
/ci stopstop watching

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
intervalSec20How often gh is asked for the state of the runs.
onRedbothpane: open the log pane. prompt: put an analysis request into the prompt (not sent). both.
soundoffon: a short sound when the runs are green or red (Windows: system sounds).
notifyredWhen 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.
prOnDesktopappapp: 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.
languageenLanguage of the texts this mod shows and writes.

License

MIT, see LICENSE.

Source 4 files
hooks/entry.tsx 30 lines
1// 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}
30
mods/ci-watch/hooks/register.tsx 453 lines
1// 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}
453
mods/settings/hooks/band.ts 44 lines
1// 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}
44
mods/ci-watch/types/contract.d.ts 41 lines
1export 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