SLOPSHOPPER

ci-light

Know when main is broken before you build on it: a red row above the prompt says how long your repo's default branch has been failing CI and how many pushes…

newbandspinnerpromptprocesstimer
★ 11v0.2.1MITupdated 2026-10-23davekiss/mods/ci-light
A shopper browsing a rack in a slop shop
README

ci-light

A Claude Code mod that tells you when main is broken, before you build on it.

  • A red row above the prompt when your repo's default branch is failing CI: how long it's been red and how many pushes landed on top of the first failure (+33 pushes).
  ● main red 37d 0h · +33 pushes  hide
  • A quiet word under the prompt otherwise: ci ● green, ◐ while a run is going.
  • The agent is told too. Once per red streak, the next prompt carries a note that main is failing, so Claude checks whether its change touches the failure before saying it works.

A push is red if any of its workflow runs failed (failure, timed_out, startup_failure, action_required), green when its runs succeeded. Pushes whose runs were all cancelled or skipped don't count either way. A + means the streak goes past the last 100 runs.

Install

/plugin marketplace add davekiss/mods
/plugin install ci-light@davekiss

Then start a new session in a GitHub repo. From a checkout: claude --plugin-dir ./ci-light.

Only the workflows that matter

A repo with one chronically failing side workflow (benchmarks, nightly) would read red forever. Set the plugin's workflow option to the ones that decide red or green, by name or file: ci, or ci.yml,test.yml. As a statusline command, use CI_LIGHT_WORKFLOW=ci.

As a plain statusline

The engine is one dependency-free Node file, and it also works as a statusline:

mkdir -p ~/.claude && cp ci-light.mjs ~/.claude/ci-light.mjs
{ "statusLine": { "type": "command", "command": "node ~/.claude/ci-light.mjs" } }

It prints main ● red 32d 22h · 33 pushes. node ci-light.mjs --json prints the result as JSON, and --fixture runs.json reads { default_branch, workflow_runs } from a file instead of GitHub.

Read it right

  • Uses gh when you're signed in (private repos too), otherwise the public GitHub API: 60 requests an hour without GITHUB_TOKEN, two per repo per refresh. A refused call shows ci ? (GitHub 403) for 30 s.
  • Each repo is cached for 120 s (CI_LIGHT_TTL in seconds).
  • Shows nothing outside a GitHub repo.

Develop

node --test tests/engine.spec.mjs runs the engine's tests.

Source 2 files
hooks/register.tsx 85 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Light } from '../types'
5
6const RED = '#E4312B'
7const REFRESH_MS = 120_000 // ci-light.mjs caches each repo for as long
8
9const light = atom({ plugin: 'ci-light', key: 'light' } as const, null as Light | null)
10const hidden = atom({ plugin: 'ci-light', key: 'hidden' } as const, null as string | null)
11const told = atom({ plugin: 'ci-light', key: 'told' } as const, null as string | null)
12
13// One red streak, so "hide" and "told the model" last until main changes.
14export const streakKey = (l: Light) => `${l.repo}:${l.state}:${l.since?.split(' ')[0] ?? ''}`
15
16// What the model reads with the next prompt while main is red; null when there's nothing to say.
17export function agentNote(l: Light | null): string | null {
18  if (l?.state !== 'red') return null
19  const on = l.pushes ? `, and ${l.pushes}${l.more ? '+' : ''} push${l.pushes === 1 && !l.more ? '' : 'es'} landed on top` : ''
20  const which = l.workflows ? ` (workflows: ${l.workflows})` : ''
21  return `ci-light: ${l.repo}'s default branch ${l.branch} has been failing CI${which} for ${l.since}${l.more ? '+' : ''}${on}. Before building on it or saying a change works, check whether your change touches the failure (gh run list --branch ${l.branch}).`
22}
23
24async function refresh($: EngineInterface, workflow: string) {
25  const argv = ['node', `${$.plugin.root}/ci-light.mjs`, '--json']
26  if (workflow) argv.push('--workflow', workflow)
27  const ran = await $.process.run(argv, { timeoutMs: 15_000 })
28  if (ran.exitCode !== 0) return
29  try {
30    const l = JSON.parse(ran.stdout) as Light | null
31    await update($, light, () => l)
32  } catch {}
33}
34
35export const register: Register = (on, options) => {
36  const workflow = String(options.workflow ?? '').trim()
37
38  on('session.start', async ($, e, next) => {
39    await refresh($, workflow)
40    $.clock.every(REFRESH_MS, () => refresh($, workflow))
41    return next(e)
42  })
43
44  on('turn.complete', async ($, e, next) => {
45    const out = await next(e)
46    await refresh($, workflow)
47    return out
48  })
49
50  // The agent sees it too: once per red streak, the next prompt carries a note.
51  on('prompt.submit', async ($, e, next) => {
52    const l = await read($, light)
53    const note = agentNote(l)
54    if (note === null || l === null || (await read($, told)) === streakKey(l)) return next(e)
55    await update($, told, () => streakKey(l))
56    return next({ ...e, context: [...(e.context ?? []), note] })
57  }).catch(($, e, next) => next(e)) // a failed note never blocks the prompt
58
59  // Red main: one row above the prompt you can't miss.
60  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61    if (e.props.hasSurvey || e.props.isWorking) return next(e)
62    const l = await read($, light)
63    if (l?.state !== 'red' || (await read($, hidden)) === streakKey(l)) return next(e)
64    const { Box, Text, Button } = $.ui.resolve(e)
65    const more = l.more ? '+' : ''
66    const onTop = l.pushes > 0 ? `· +${l.pushes}${more} push${l.pushes === 1 && !more ? '' : 'es'}` : '' // pushes landed on top of the first red one
67    return (
68      <Box gap={1}>
69        <Text color={RED} bold>{`● ${l.branch} red ${l.since}${more}`}</Text>
70        {onTop && <Text>{onTop}</Text>}
71        {l.workflows && <Text dimColor>{l.workflows}</Text>}
72        <Button key="ci-light-hide" plain dimColor label="hide" onPress={() => update($, hidden, () => streakKey(l))} />
73      </Box>
74    )
75  })
76
77  // Otherwise a quiet word under the prompt: green, running, or red once hidden.
78  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
79    const l = await read($, light)
80    if (!l || l.state === 'error') return next(e)
81    const word = l.state === 'red' ? `● red ${l.since}` : l.state === 'green' ? `● green${l.running ? ' ◐' : ''}` : l.text
82    return next({ ...e, props: { ...e.props, tail: `ci ${word}` } })
83  })
84}
85
types/index.d.ts 21 lines
1// The light, as ci-light.mjs --json writes it.
2export type Light = {
3  repo: string | null
4  branch: string | null
5  state: 'red' | 'green' | 'running' | 'unknown' | 'none' | 'error'
6  since: string | null
7  pushes: number
8  more: boolean
9  running: boolean
10  workflows: string | null
11  error?: string
12  text: string
13  line: string
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'ci-light': { light: Light | null; hidden: string | null; told: string | null }
19  }
20}
21