In CleanExpo/Synthex sessions, shows a status line while Synthex main CI is red (read-only gh run list every two minutes)

Claude Code mod that puts a status line under the prompt while CleanExpo/Synthex main CI is red. Synthex main sat red for two weeks (SYN-1205 / SYN-1211) without anyone noticing; this makes it visible in every Synthex session.
Linear: RA-7907 · Mods docs: docs/reference/claude-mods/
Only in a session whose repo remote is CleanExpo/Synthex (any other repo: nothing runs, no timer). At start and then every two minutes it runs, read-only:
gh run list --repo CleanExpo/Synthex --branch main --limit 1 --json conclusion,status,displayTitle,url
| Latest run on main | Status line |
|---|---|
failure, cancelled, timed_out | main is RED: <title, up to 50 chars> |
success | cleared |
| in progress, gh missing / not signed in / timed out, non-zero exit, no runs, bad output, other conclusions | main CI status unknown (<reason>) |
A failed read never clears the line. Once the first read has finished, no line means green. Before that (the first read runs in the background at session start and can take up to 20 s), no line means "not checked yet". The line is updated only when it changes. It writes nothing to GitHub or anywhere else.
Needs gh signed in on the machine (gh auth status).
claude plugin validate mods/synthex-main-status
(cd mods/synthex-main-status && claude plugin test)
From the pi-dev-ops-mods marketplace (see mods/mc-lane/README.md for marketplace setup and auto-update):
claude plugin install synthex-main-status@pi-dev-ops-mods
Try it in one session first, from a Synthex checkout, pointing at this repository's copy: claude --plugin-dir /path/to/Pi-Dev-Ops/mods/synthex-main-status.
hooks/register.ts 63 lines1// synthex-main-status — a status line while CleanExpo/Synthex main CI is red.
2//
3// Why: Synthex main sat red for two weeks (SYN-1205 / SYN-1211) with nobody
4// noticing. In a session whose repo is CleanExpo/Synthex this reads the latest
5// `main` run with `gh run list` every two minutes (and once at start) and shows
6// `main is RED: <title>` under the prompt until a run succeeds.
7//
8// Read-only: one `gh run list` per tick, nothing written anywhere. A read that
9// fails (gh missing, not signed in, timeout, bad output) shows "unknown", never
10// a cleared line — no line must only ever mean green.
11//
12// Functions that take `$` are top-level declarations: the engine scans the
13// module before loading it and refuses `$` handed to anything else.
14
15import type { EngineInterface, Register } from 'claude-code'
16import { decideStatus, isSynthex, SYNTHEX, type GhResult } from './status'
17
18const EVERY_MS = 120_000
19const GH_TIMEOUT_MS = 20_000
20const ARGV = [
21 'gh', 'run', 'list', '--repo', SYNTHEX, '--branch', 'main', '--limit', '1',
22 '--json', 'conclusion,status,displayTitle,url',
23]
24
25// Module state. A reload starts it over. `shown` is what the status line says
26// now; undefined means no line.
27const st = { shown: undefined as string | undefined, inFlight: false }
28
29async function readMain($: EngineInterface): Promise<GhResult> {
30 try {
31 const r = await $.process.run(ARGV, { timeoutMs: GH_TIMEOUT_MS })
32 return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
33 } catch (err) {
34 return { error: err instanceof Error ? err.message : String(err) }
35 }
36}
37
38// One read at a time; a tick that lands while one is out is skipped.
39async function check($: EngineInterface): Promise<void> {
40 if (st.inFlight) return
41 st.inFlight = true
42 try {
43 const want = decideStatus(await readMain($))
44 if (want !== st.shown) {
45 $.ui.status(want)
46 st.shown = want
47 }
48 } finally {
49 st.inFlight = false
50 }
51}
52
53export const register: Register = on => {
54 on('session.start', async ($, e, next) => {
55 const repo = await $.session.repo()
56 if (!isSynthex(repo?.remote)) return next(e)
57 // First read right away, but off the start path so the session isn't held for gh.
58 $.clock.after(0, () => check($))
59 $.clock.every(EVERY_MS, () => check($))
60 return next(e)
61 })
62}
63hooks/status.ts 67 lines1// Pure helpers for synthex-main-status: no `$`, so they unit-test without the kit.
2//
3// The one rule: a status line that is absent means "main is green". So nothing
4// but a run that completed with `success` may clear it. Every failure to read
5// the run says "unknown", never nothing.
6
7export const SYNTHEX = 'CleanExpo/Synthex'
8
9/** What `$.process.run` resolved to, or the message it rejected with. */
10export type GhResult =
11 | { exitCode: number; stdout: string; stderr: string }
12 | { error: string }
13
14const RED = new Set(['failure', 'cancelled', 'timed_out'])
15const TITLE_MAX = 50
16
17/** `owner/name` from a git remote URL, or undefined. Copied from mods/mc-lane/hooks/lane.ts (mods are self-contained). */
18export function repoSlug(remote: string | null | undefined): string | undefined {
19 if (!remote) return undefined
20 const m = /[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(remote.trim())
21 return m ? `${m[1]}/${m[2]}` : undefined
22}
23
24/** True only for a github.com remote whose owner/name is CleanExpo/Synthex (any case). */
25export function isSynthex(remote: string | null | undefined): boolean {
26 if (!remote || !/(^|[@/])github\.com[:/]/i.test(remote.trim())) return false
27 return repoSlug(remote)?.toLowerCase() === SYNTHEX.toLowerCase()
28}
29
30const unknown = (reason: string): string => `main CI status unknown (${reason})`
31
32function title(raw: unknown): string {
33 const t = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : ''
34 if (!t) return '(untitled run)'
35 return t.length > TITLE_MAX ? t.slice(0, TITLE_MAX - 1) + '…' : t
36}
37
38function failureReason(r: GhResult): string | undefined {
39 if ('error' in r) return /ENOENT|not found|no such file/i.test(r.error) ? 'gh not installed' : 'gh did not run'
40 if (r.exitCode === 0) return undefined
41 if (/auth login|not logged|authenticat/i.test(r.stderr)) return 'gh not signed in'
42 return `gh exited ${r.exitCode}`
43}
44
45/**
46 * The status line for one `gh run list --json conclusion,status,displayTitle,url`
47 * read: a RED line, an unknown line, or undefined (clear: main is green).
48 */
49export function decideStatus(r: GhResult): string | undefined {
50 const failed = failureReason(r)
51 if (failed) return unknown(failed)
52 let runs: unknown
53 try {
54 runs = JSON.parse((r as { stdout: string }).stdout)
55 } catch {
56 return unknown('unparseable gh output')
57 }
58 if (!Array.isArray(runs)) return unknown('unparseable gh output')
59 if (runs.length === 0) return unknown('no runs on main')
60 const run = runs[0] as { conclusion?: unknown; status?: unknown; displayTitle?: unknown }
61 if (run.status !== 'completed') return unknown('run in progress')
62 const conclusion = typeof run.conclusion === 'string' ? run.conclusion : ''
63 if (RED.has(conclusion)) return `main is RED: ${title(run.displayTitle)}`
64 if (conclusion === 'success') return undefined
65 return unknown(`conclusion ${conclusion || 'missing'}`)
66}
67