SLOPSHOPPER

synthex-main-status

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

newstatusprocesstimer
★ 1v?no licenseupdated 2026-10-05CleanExpo/Pi-Dev-Ops/mods/synthex-main-status
A shopper browsing a rack in a slop shop
README

synthex-main-status

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/

What it does

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 mainStatus line
failure, cancelled, timed_outmain is RED: <title, up to 50 chars>
successcleared
in progress, gh missing / not signed in / timed out, non-zero exit, no runs, bad output, other conclusionsmain 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).

Check and test

claude plugin validate mods/synthex-main-status
(cd mods/synthex-main-status && claude plugin test)

Install

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.

Source 2 files
hooks/register.ts 63 lines
1// 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}
63
hooks/status.ts 67 lines
1// 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