SLOPSHOPPER

github-status

A line above the prompt with the session repo's running actions, open pull requests and open issues, read from GitHub and never changed

newbandcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · github-status
› fix the failing auth test and add an audit log call ⏺ 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 › /github ⎿ github-status: This session's folder has no GitHub origin remote, so there's nothing to read. ◆ github no GitHub remote ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ github no GitHub remote ⟨Claude Code's own drawing⟩
README

GitHub status

Whether a pull request is waiting or a workflow is still running lives on GitHub, a browser tab away from the session. This mod draws one line above the prompt with the repo's running actions, open pull requests and open issues:

◆ github  SanvioLabs/sanvio-harness-starter · 1 action running (2 queued) · 3 open pull requests · 5 open issues

It's a Claude Code mod, so it works in Claude Code only: it was seen working in the terminal, the VS Code extension and Claude Desktop's Code tab on 2026-10-23, with Claude Code 2.1.309. It was built on Claude Code 2.1.301. Codex and Kiro don't have mods.

Turn it on

It loads by itself, the same way as the harness lens: the harness's .claude/settings.json turns it on. Don't see the line? Install it from inside a session, at the root:

/plugin marketplace add ./
/plugin install github-status@sanvio-harness-starter

It's active as soon as the install says so, no restart. Or load it for one session with claude --plugin-dir examples/mods/github-status.

It reads GitHub through the gh command line, so gh has to be installed and signed in (gh auth status says).

To turn it off just for you:

claude plugin disable github-status@sanvio-harness-starter --scope local

Reading the line

The line saysMeans
owner/name · reading…, cyanThe first read of GitHub is under way
owner/name · 2 actions running (1 queued) · 3 open pull requests · 5 open issues, cyanWhat the last read found. Actions are workflow runs in progress, and the queued count shows only when there is one. Issues don't include pull requests
… · as of 7m ago, yellowThe last read failed, so these are the counts from the one before, and how old they are
owner/name · GitHub unavailable (is gh installed and signed in?), yellowNo read has worked yet this session. gh is missing, signed out, offline, or can't see the repo
no GitHub remote, dimThe session's folder has no origin remote on github.com, so the mod never calls gh

/github reads GitHub straight away and prints the line.

Your projects

The line is the harness's repo. Repos cloned under projects/ are read with the command, from a session at the harness root:

TypePrints
/github acme (or /github projects/acme)The line for the repo cloned in projects/acme, read now
/github allThe harness's line, then one per folder under projects/ with a GitHub origin, in name order, then how many folders have none
harness: SanvioLabs/sanvio-harness-starter · 0 actions running · 2 open pull requests · 5 open issues
acme: acme/app · 1 action running · 4 open pull requests · 12 open issues
1 folder under projects/ has no GitHub remote.

A name is one folder directly under projects/. Anything that could reach outside it, such as ../x, a/b or a leading ., is refused before anything runs. A folder with no GitHub origin says so and gh isn't called. A repo whose read fails says GitHub is unavailable on its own line, and the others still print. Reading a project never changes the line above the prompt.

What it doesn't do

It only reads. It never changes anything on GitHub. When the session starts it runs git remote get-url origin in the folder the session started in (and, for /github <name> or /github all, in each folder under projects/ it reads), and on a github.com remote, at most once every two minutes, it makes three reads through gh, each with a time limit:

  • gh api graphql with a query (never a mutation) for the open pull request and open issue counts
  • gh api --method GET repos/owner/name/actions/runs, once for runs in progress and once for queued ones

/github reads whenever you type it. Drawing the line never calls gh. A read that fails, for any reason, shows as unavailable or stale and stops nothing.

The line shows the repo the session started in, from its origin remote. Other repos are read only through /github <name> and /github all, from folders under projects/. It doesn't choose another remote, list workflows one by one, or link to GitHub.

Changing it

hooks/logic.ts holds the decisions as plain functions, so they can be tested. hooks/register.tsx runs git and gh and draws the line. From examples/mods/:

claude plugin validate github-status
claude plugin test github-status

python3 -m unittest discover -s tests, and so the loop, runs these wherever Claude Code is installed. CI doesn't install Claude Code, so it skips them.

Source 3 files
hooks/register.tsx 174 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { QUERY, allText, due, failed, lineFor, parseCounts, parseRemote, projectName } from './logic'
5import type { Tone } from './logic'
6import type { Counts, GitHubState } from '../types'
7
8const COMMAND = 'github'
9// How often the timer asks whether a read is due. due() holds reads to one per two minutes.
10const TICK_MS = 15_000
11const GIT_TIMEOUT_MS = 5_000
12const GH_TIMEOUT_MS = 15_000
13
14const launchAtom = atom({ plugin: 'github-status', key: 'launch' } as const, null)
15const statusAtom = atom({ plugin: 'github-status', key: 'status' } as const, null)
16const attemptAtom = atom({ plugin: 'github-status', key: 'attempt' } as const, null)
17
18const COLORS: Record<Tone, { color?: string; dimColor?: boolean }> = {
19  quiet: { dimColor: true },
20  now: { color: 'cyan' },
21  warn: { color: 'yellow' },
22}
23
24// Set on session.start from the origin remote. Null means no GitHub remote, and gh is never called.
25let repo: string | null = null
26let isReading = false
27
28async function originRepo($: EngineInterface, cwd: string): Promise<string | null> {
29  const ran = await $.process.run(['git', '-C', cwd, 'remote', 'get-url', 'origin'], { timeoutMs: GIT_TIMEOUT_MS }).catch(() => null)
30  return ran && ran.exitCode === 0 ? parseRemote(ran.stdout) : null
31}
32
33// A read through gh. Each call is a GET or a GraphQL query: nothing here changes GitHub.
34async function gh($: EngineInterface, args: string[]): Promise<string> {
35  const ran = await $.process.run(['gh', ...args], { timeoutMs: GH_TIMEOUT_MS })
36  if (ran.exitCode !== 0) throw new Error('gh failed')
37  return ran.stdout
38}
39
40const runs = (name: string, status: 'in_progress' | 'queued') =>
41  ['api', '--method', 'GET', `repos/${name}/actions/runs?status=${status}&per_page=1`]
42
43// One repo's counts, from the three reads. Throws when any read fails or answers in the wrong shape.
44async function readCounts($: EngineInterface, name: string): Promise<Counts> {
45  const [owner, short] = name.split('/') as [string, string]
46  const [graphql, running, queued] = await Promise.all([
47    gh($, ['api', 'graphql', '-f', `query=${QUERY}`, '-f', `owner=${owner}`, '-f', `name=${short}`]),
48    gh($, runs(name, 'in_progress')),
49    gh($, runs(name, 'queued')),
50  ])
51  const counts = parseCounts(graphql, running, queued)
52  if (!counts) throw new Error('unexpected answer')
53  return counts
54}
55
56// A project's line for /github, read now. Unavailable when the read fails.
57async function projectLine($: EngineInterface, name: string): Promise<string> {
58  try {
59    return lineFor({ kind: 'counts', repo: name, counts: await readCounts($, name), at: Date.now() }, Date.now()).text
60  } catch {
61    return lineFor({ kind: 'unavailable', repo: name }, Date.now()).text
62  }
63}
64
65// The folders directly under projects/ at the harness root, by name. None when there's no projects/.
66async function projectFolders($: EngineInterface, root: string): Promise<string[] | null> {
67  const entries = await $.fs.list(`${root}/projects`).catch(() => null)
68  if (!entries) return null
69  return entries.filter(f => f.kind === 'dir' && !f.name.startsWith('.')).map(f => f.name).sort()
70}
71
72// A project folder's GitHub repo, only when the folder is a clone of its own. A plain folder has no .git,
73// and git there would answer with the harness's remote instead.
74async function projectRepo($: EngineInterface, root: string, name: string): Promise<string | null> {
75  const folder = `${root}/projects/${name}`
76  return (await $.fs.exists(`${folder}/.git`).catch(() => false)) ? originRepo($, folder) : null
77}
78
79// /github <name>: one project's repo.
80async function oneProject($: EngineInterface, root: string, name: string): Promise<string> {
81  const folders = await projectFolders($, root)
82  if (!folders) return "There's no projects/ folder where this session started."
83  if (!folders.includes(name)) return `projects/${name} isn't a folder.`
84  const found = await projectRepo($, root, name)
85  if (!found) return `projects/${name} has no GitHub origin remote, so there's nothing to read.`
86  return `${name}: ${await projectLine($, found)}`
87}
88
89// /github all: the harness, then every project with a GitHub origin.
90async function allProjects($: EngineInterface, root: string): Promise<string> {
91  // Read now, like the projects, rather than through the line's state: a read already under way
92  // would leave that empty.
93  const harness = repo ? await projectLine($, repo) : lineFor({ kind: 'no-remote' }, Date.now()).text
94  const folders = (await projectFolders($, root)) ?? []
95  const remotes = await Promise.all(folders.map(async name => ({ name, found: await projectRepo($, root, name) })))
96  const withRemote = remotes.filter((r): r is { name: string; found: string } => r.found !== null)
97  const projects = await Promise.all(withRemote.map(async r => ({ name: r.name, text: await projectLine($, r.found) })))
98  return allText(harness, projects, folders.length - withRemote.length, folders.length)
99}
100
101// One read of GitHub, if one is due. Every failure is caught and shows as unavailable, or as stale counts.
102async function refresh($: EngineInterface, isAsked = false): Promise<void> {
103  const name = repo
104  if (!name || isReading) return
105  const now = Date.now()
106  if (!due(await read($, attemptAtom), now, isAsked)) return
107  isReading = true
108  try {
109    await update($, attemptAtom, () => now)
110    await update($, statusAtom, (prev): GitHubState | null => (prev && prev.kind !== 'no-remote' && prev.repo === name ? prev : { kind: 'reading', repo: name }))
111    const counts = await readCounts($, name)
112    await update($, statusAtom, (): GitHubState => ({ kind: 'counts', repo: name, counts, at: Date.now() }))
113  } catch {
114    await update($, statusAtom, prev => failed(prev, name)).catch(() => {})
115  } finally {
116    isReading = false
117  }
118}
119
120export const register: Register = on => {
121  on('session.start', async ($, e, next) => {
122    // A reload fires session.start again; the directory the session started in is the first one.
123    await update($, launchAtom, prev => prev ?? e.cwd)
124    await $.command.register({
125      name: COMMAND,
126      description: "Read GitHub now: the repo's running actions, open pull requests and open issues. /github <project> or /github all for repos under projects/",
127    })
128    const launch = await read($, launchAtom)
129    repo = launch ? await originRepo($, launch) : null
130    if (!repo) await update($, statusAtom, () => ({ kind: 'no-remote' }))
131    else {
132      void refresh($).catch(() => {})
133      $.clock.every(TICK_MS, () => void refresh($).catch(() => {}))
134    }
135    return next(e)
136  })
137
138  on('command.run', { command: COMMAND }, async ($, e) => {
139    const arg = e.args.trim()
140    if (arg) {
141      // Another repo, read once now. The line above the prompt stays the harness's.
142      const root = await read($, launchAtom)
143      if (!root) return { text: 'This session has no folder yet.' }
144      if (arg === 'all') return { text: await allProjects($, root) }
145      const picked = projectName(arg)
146      if ('why' in picked) return { text: picked.why }
147      return { text: await oneProject($, root, picked.name) }
148    }
149    if (!repo) return { text: "This session's folder has no GitHub origin remote, so there's nothing to read." }
150    await refresh($, true).catch(() => {})
151    const status = await read($, statusAtom)
152    return { text: status ? lineFor(status, Date.now()).text : 'Nothing read yet.' }
153  })
154
155  // Drawn above the prompt, then whatever another mod draws there: two bands stack, neither hides the other.
156  // It only reads the atom: GitHub is read on the timer and by /github, never while drawing.
157  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158    const status = await read($, statusAtom)
159    if (!status || e.props.hasSurvey) return next(e)
160    const { Box, Text } = $.ui.resolve(e)
161    const line = lineFor(status, Date.now())
162    const below = await next(e)
163    return (
164      <Box flexDirection="column">
165        <Box flexWrap="wrap" columnGap={2}>
166          <Text bold>◆ github</Text>
167          <Text {...COLORS[line.tone]}>{line.text}</Text>
168        </Box>
169        {below}
170      </Box>
171    )
172  })
173}
174
hooks/logic.ts 114 lines
1// The decisions, as pure functions. register.tsx runs git and gh and draws the line, then calls in here,
2// because the test kit can't stand in for a process.
3
4import type { Counts, GitHubState } from '../types'
5
6// At most one read of GitHub per repo in this long, unless the person asks with /github.
7export const LIMIT_MS = 2 * 60_000
8
9const PART = '[A-Za-z0-9_.-]+'
10const REMOTES = [
11  new RegExp(`^git@github\\.com:(${PART})/(${PART}?)(?:\\.git)?/?$`),
12  new RegExp(`^ssh://git@github\\.com(?::\\d+)?/(${PART})/(${PART}?)(?:\\.git)?/?$`),
13  new RegExp(`^https://(?:[^@/]+@)?github\\.com/(${PART})/(${PART}?)(?:\\.git)?/?$`),
14]
15
16// `owner/name` for a github.com remote, over SSH or HTTPS. Anything else is none, and gh is never called.
17export function parseRemote(url: string): string | null {
18  const text = url.trim()
19  for (const pattern of REMOTES) {
20    const match = text.match(pattern)
21    const owner = match?.[1]
22    const name = match?.[2]
23    if (!owner || !name || [owner, name].some(part => part === '.' || part === '..')) continue
24    return `${owner}/${name}`
25  }
26  return null
27}
28
29// The GraphQL read, a query and never a mutation. issues(states: OPEN) leaves pull requests out.
30export const QUERY =
31  'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { pullRequests(states: OPEN) { totalCount } issues(states: OPEN) { totalCount } } }'
32
33function json(text: string): unknown {
34  try {
35    return JSON.parse(text)
36  } catch {
37    return null
38  }
39}
40
41const count = (value: unknown): number | null => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null)
42
43// The counts from the GraphQL answer and the two workflow-run listings. None when any is the wrong shape.
44export function parseCounts(graphqlJson: string, runningJson: string, queuedJson: string): Counts | null {
45  const repo = (json(graphqlJson) as { data?: { repository?: Record<string, { totalCount?: unknown } | undefined> } } | null)?.data?.repository
46  const pulls = count(repo?.pullRequests?.totalCount)
47  const issues = count(repo?.issues?.totalCount)
48  const running = count((json(runningJson) as { total_count?: unknown } | null)?.total_count)
49  const queued = count((json(queuedJson) as { total_count?: unknown } | null)?.total_count)
50  if (pulls === null || issues === null || running === null || queued === null) return null
51  return { running, queued, pulls, issues }
52}
53
54// Whether a read may start: the first one, one two minutes after the last, or one the person asked for.
55export function due(lastAttempt: number | null, now: number, isAsked = false): boolean {
56  return isAsked || lastAttempt === null || now - lastAttempt >= LIMIT_MS
57}
58
59// A read that failed keeps the last counts, marked stale. With none to keep, GitHub is unavailable.
60export function failed(state: GitHubState | null, repo: string): GitHubState {
61  return state && (state.kind === 'counts' || state.kind === 'stale') && state.repo === repo
62    ? { kind: 'stale', repo, counts: state.counts, at: state.at }
63    : { kind: 'unavailable', repo }
64}
65
66export type Tone = 'quiet' | 'now' | 'warn'
67export type Line = { text: string; tone: Tone }
68
69const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
70
71function countsText(repo: string, c: Counts): string {
72  const actions = `${plural(c.running, 'action', 'actions')} running${c.queued ? ` (${c.queued} queued)` : ''}`
73  return `${repo} · ${actions} · ${plural(c.pulls, 'open pull request', 'open pull requests')} · ${plural(c.issues, 'open issue', 'open issues')}`
74}
75
76function age(ms: number): string {
77  const minutes = Math.max(0, Math.floor(ms / 60_000))
78  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
79}
80
81// The line above the prompt, after its `◆ github` label.
82export function lineFor(state: GitHubState, now: number): Line {
83  switch (state.kind) {
84    case 'no-remote':
85      return { text: 'no GitHub remote', tone: 'quiet' }
86    case 'reading':
87      return { text: `${state.repo} · reading…`, tone: 'now' }
88    case 'counts':
89      return { text: countsText(state.repo, state.counts), tone: 'now' }
90    case 'unavailable':
91      return { text: `${state.repo} · GitHub unavailable (is gh installed and signed in?)`, tone: 'warn' }
92    case 'stale':
93      return { text: `${countsText(state.repo, state.counts)} · as of ${age(now - state.at)} ago`, tone: 'warn' }
94  }
95}
96
97// The folder under projects/ that a /github argument names: `acme` or `projects/acme`, one folder deep.
98// Anything that could reach outside projects/ is refused before anything runs.
99export function projectName(arg: string): { name: string } | { why: string } {
100  const name = arg.trim().replace(/^projects\//, '').replace(/\/$/, '')
101  if (/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) && !name.includes('..')) return { name }
102  return { why: `"${arg.trim()}" isn't a project: give the name of a folder under projects/, such as /github acme, or /github all.` }
103}
104
105// What /github all prints: the harness's line, each project's in name order, then the folders it couldn't read.
106export function allText(harness: string, projects: { name: string; text: string }[], withoutRemote: number, folders: number): string {
107  const lines = [`harness: ${harness}`, ...projects.map(p => `${p.name}: ${p.text}`)]
108  if (folders === 0) lines.push('No folders under projects/.')
109  else if (withoutRemote > 0) {
110    lines.push(`${withoutRemote} ${withoutRemote === 1 ? 'folder' : 'folders'} under projects/ ${withoutRemote === 1 ? 'has' : 'have'} no GitHub remote.`)
111  }
112  return lines.join('\n')
113}
114
types/index.d.ts 19 lines
1// What one read of GitHub found: workflow runs in progress and queued, open pull requests, open issues.
2export type Counts = { running: number; queued: number; pulls: number; issues: number }
3
4// The line's state. `repo` is `owner/name`, from the session's origin remote. `at` is when the counts
5// were read, in milliseconds. Stale is a later read that failed: the last counts, with their age.
6export type GitHubState =
7  | { kind: 'no-remote' }
8  | { kind: 'reading'; repo: string }
9  | { kind: 'counts'; repo: string; counts: Counts; at: number }
10  | { kind: 'unavailable'; repo: string }
11  | { kind: 'stale'; repo: string; counts: Counts; at: number }
12
13declare module 'claude-code' {
14  interface PluginState {
15    // `attempt` is when the last read of GitHub began, so a reload doesn't read again early.
16    'github-status': { launch: string | null; status: GitHubState | null; attempt: number | null }
17  }
18}
19