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

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.
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
| The line says | Means |
|---|---|
owner/name · reading…, cyan | The first read of GitHub is under way |
owner/name · 2 actions running (1 queued) · 3 open pull requests · 5 open issues, cyan | What 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, yellow | The 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?), yellow | No read has worked yet this session. gh is missing, signed out, offline, or can't see the repo |
no GitHub remote, dim | The 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.
The line is the harness's repo. Repos cloned under projects/ are read with the command, from a session at the harness root:
| Type | Prints |
|---|---|
/github acme (or /github projects/acme) | The line for the repo cloned in projects/acme, read now |
/github all | The 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.
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 countsgh 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.
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.
hooks/register.tsx 174 lines1import { 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}
174hooks/logic.ts 114 lines1// 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}
114types/index.d.ts 19 lines1// 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