The CI weather of your firstmate fleet's open PRs, one glyph per PR in the band above the prompt

<img width="2306" height="1937" alt="image" src="https://github.com/user-attachments/assets/b7c507c9-6256-40af-9023-78929b30ab83" />
Claude Code mods for firstmate fleets, published as a Claude Code plugin marketplace.
| Mod | What it does |
|---|---|
| fleet-lamp | A lamp above the prompt: red when the fleet needs you, green when a PR is ready. |
| pr-weather | The CI weather of the fleet's open PRs above the prompt: one glyph per PR. |
claude plugin marketplace add jayjongcheolpark/firstmate-mods
claude plugin install fleet-lamp@firstmate-mods
claude plugin install pr-weather@firstmate-mods
To try a local checkout without installing it, start a session with the plugin folder loaded:
claude --plugin-dir /path/to/firstmate-mods/plugins/fleet-lamp
claude --plugin-dir /path/to/firstmate-mods/plugins/pr-weather
A band above the prompt that tells you when the fleet needs a human. You don't need a lamp.
A task needs a decision (red), with one more open red behind it:

A PR is ready for review (green):
https://github.com/acme/webapp/pull/7 fix-login"" loading="lazy">
Both images come from a live session at 90 columns: the terminal screen the session drew, rendered with a dark palette. The band draws its dot in the theme's error color for red and its success color for green, and draws the task (or PR ready) in bold.
When nothing needs you, the band shows nothing.
[key=...], [at=...] or [corr=...] tags and the colon), so needs-decision [key=pick]: A or B? shows as needs-decision: A or B?. The newest open red comes first, and +N more counts the others.The lamp is always one line. When it does not fit the band, the reason (or, for green, the task after the URL) is cut first, with …, down to 16 cells; then the task (or the URL), down to 12 cells; then the reason goes. The dot and +N more are never cut. The mod measures in terminal cells, so Korean and other wide text counts double, and in the terminal it leaves the last four cells for the band's [-] control.
The band reads only task.status and task.pr_ready records from firstmate's fleet activity ledger, state/fleet-ledger.jsonl (see docs/fleet-ledger.md in the firstmate repository), in the session's own firstmate home only.
task.status record whose state is needs-decision, blocked or failed. Two kinds of record are left out:ask-user findings=);Captain 2026-09-30 verbatim ...).[key=...] decision key) turns off by itself when a later resolved record carries the same task and key.task.pr_ready record, or a done record that reports a ready PR (PR https://..., child X done: PR https://..., PR ready: https://...). A done record that says the PR already landed or merged does not count. Green stays until your next prompt.These are the same rules as the lamp script that inspired this mod.
The ledger is opt-in. In your firstmate home, create the flag:
touch config/fleet-ledger
Delete the flag to turn the ledger off. While the ledger is off, the band shows nothing. The first time a session finds the home's ledger off, the mod adds one dim line to the transcript that tells you how to turn it on.
The mod looks for the firstmate home at or above the session's working directory: the nearest directory whose AGENTS.md has a # Firstmate heading line and that has a state/ folder. Start your firstmate session in its home, as usual, and the mod finds it. A claude -p run or an SDK session, where no one is at the prompt, follows no ledger.
To follow a home from somewhere else, set the plugin's home option in /config, or from a shell:
echo '{"home": "~/firstmate"}' | claude plugin configure fleet-lamp@firstmate-mods --values-stdin
The mod does not follow the ledgers of second mate homes. You do not see a second mate's decisions, so a red in a second mate's ledger is not yours yet. When the second mate passes a decision up, the main firstmate records it in the main ledger, and the band turns red then.
The mod reads only the config/fleet-ledger flag and state/fleet-ledger.jsonl in the home. It reads no other state file, makes no network calls and controls no hardware.
$.fs.read reads whole files of at most 4 MiB. So the mod reads the new bytes with tail -c +<offset>.claude plugin validate . # the marketplace
claude plugin validate plugins/fleet-lamp # the plugin and its hooks module
claude plugin test plugins/fleet-lamp # the rule, line fitting and band tests
npx -p typescript tsc -p plugins/fleet-lamp # type-check, once a session has loaded the mod
A session that loads the mod from a folder you own (--plugin-dir) writes the API types to plugins/fleet-lamp/.claude-plugin/types/, which tsconfig.json extends.
The rules are pure functions in plugins/fleet-lamp/hooks/rules.ts, and the one-line fitting is in plugins/fleet-lamp/hooks/line.ts. The hooks module that reads the ledger and draws the band is plugins/fleet-lamp/hooks/register.tsx.
A band above the prompt with the CI weather of the PRs your firstmate fleet is working on, one glyph per PR:
PRs ☂ #7 ↯ #9 ⚔ #11 ✎ #10 ☀ #12 +2 more updated 2m ago [ ↻ ] [ auto ]
| Glyph | Meaning |
|---|---|
| ↯ (magenta) | A workflow run on the PR's head commit is held for approval: a human has to approve it. |
| ☂ (red) | A check failed, was cancelled or timed out. |
| ⚔ (red) | The PR has merge conflicts with its base branch. While GitHub is still computing mergeability, the PR shows its CI glyph. |
| ☁ (yellow) | Checks are pending, queued or in progress. |
| ✎ (gray) | The PR is a draft. |
| ☀ (green) | Every check passed. |
| - (gray) | The PR has no checks. |
When more than one applies, the first in the table wins: a draft with a failed check shows ☂.
After the PRs come:
+N more when the PRs do not fit on one line;updated 2m ago, the time of the last successful refresh;(stale) when the last refresh failed, so the glyphs are from an earlier one. When the lookup of one PR fails, that PR alone keeps its last glyph, or stays out of the band until a lookup succeeds;low quota, every 12m while auto mode backs off, or rate-limited, resets in 17m while GitHub's rate limit holds every refresh;[ ↻ ] ([ … ] while a refresh runs) and the mode button [ auto ] or [ manual ].Press a PR's #N to open it, in any terminal:
#N.ctrl+x tab, move to #N with tab, and press Enter.#N.What a press does depends on where the session runs:
open <url>, which opens the PR in your default browser, and shows Opened PR #N.DISPLAY or WAYLAND_DISPLAY set) runs xdg-open <url> and shows Opened PR #N.Copied PR #N URL. That covers an SSH session (SSH_CONNECTION, SSH_TTY or SSH_CLIENT set) and a Linux server without a desktop, such as one you reach through a terminal multiplexer. In the terminal the copy goes through the terminal's clipboard (OSC 52, as /copy does), so it lands on the machine you are sitting at. Paste the URL into your browser.open exited 1; copied PR #N URL, or open failed: <error>; copied PR #N URL. If the copy fails too, the toast shows the reason and the URL itself.Each press also writes lines to the Claude Code debug log, which Claude Code writes only when you start it with claude --debug or --debug-file <path>. Find them with grep 'pr-weather press'. The first line shows that the press arrived. The second line shows the opener argv and its result:
pr-weather press #7 https://github.com/acme/webapp/pull/7 opener=none pressed
pr-weather press #7 https://github.com/acme/webapp/pull/7 opener=["open","https://github.com/acme/webapp/pull/7"] exit=0 stderr=""
In the second line, exit=<code> stderr="<text>" means the opener ran. error="<message>" means it could not run, for example a timeout. opener=none copy=no-local-browser means the session has no local browser to open.
The mod opens only https://github.com/<owner>/<repo>/pull/<n> URLs, runs the opener without a shell, and gives it 10 seconds.
The weather glyph before each #N is also a terminal hyperlink to the PR (cmd-click, handled by the terminal itself) where Claude Code draws terminal hyperlinks: Ghostty, iTerm2, WezTerm, kitty, Alacritty, Warp, Hyper and the VS Code terminal. If your terminal supports OSC 8 hyperlinks but is not on that list (Kaku, for example), set FORCE_HYPERLINK=1 in Claude Code's environment. On the desktop the glyph is always a link.
When there are no open PRs, the band shows PRs none and the buttons.
To refresh now, press [ ↻ ] or run /pr-weather refresh. That works in both modes, at most once every 30 seconds.
To switch modes, press the mode button or run /pr-weather mode auto or /pr-weather mode manual. The choice is kept across sessions until you change the mode setting itself.
To press a band button from the keyboard, focus the band with ctrl+x tab and press r (refresh) or m (mode), or move to a button with tab and press Enter. In the fullscreen layout you can also click them.
Each refresh first reads your remaining GitHub quota with gh api rate_limit, which does not count against the quota.
With the default fleet source, the PRs come from firstmate's fleet activity ledger, state/fleet-ledger.jsonl (see docs/fleet-ledger.md in the firstmate repository):
task.pr_ready record adds its PR, and a later one for the same task replaces it;done status record that reports a ready PR does the same, by fleet-lamp's green rule (PR https://..., PR ready: https://..., child X done: PR https://...);task.merged or task.cleaned_up record for that task removes it, and so does a done record that says the PR landed or merged;The ledger is opt-in: create the flag config/fleet-ledger in your firstmate home. Until the ledger exists, the band shows nothing, and the mod adds one dim line to the transcript that tells you how to turn it on.
The mod also reads the ledgers of the second mate homes registered in the home's data/secondmates.md: every entry with (home: <absolute path>; ...) whose directory exists on this machine. Remote entries (host: ...; root: ...; home: ...) are skipped, so the mod makes no SSH calls. Unlike fleet-lamp, which follows the main home alone, pr-weather shows second mate PRs. The band shows the PRs of every home, each PR once. A second mate home without a ledger is left out, with one dim line in the transcript that names it. While the main home has no ledger but a second mate does, the band shows the second mate's PRs and the mod adds the turn-it-on line once. Each refresh reads data/secondmates.md again, so a new second mate shows on the next refresh. Turn off includeSecondMates to read the main home alone.
The band shows only in a firstmate session: one whose working directory is at or under a firstmate home (a directory whose AGENTS.md has a # Firstmate heading line and that has a state/ folder). In any other session the mod draws nothing and makes no calls. With either source, a claude -p run or an SDK session, where no one is at the prompt, also draws nothing and makes no calls.
With the mine source, the band shows your own open PRs in the session's repository (gh pr list --author @me), in any session inside a git repository.
Set these in /config, or from a shell:
echo '{"mode": "manual", "refreshMinutes": 5}' | claude plugin configure pr-weather@firstmate-mods --values-stdin
| Setting | Default | What it does |
|---|---|---|
source | fleet | fleet: the fleet ledger's PRs, in firstmate sessions. mine: your open PRs in the session's repository. |
mode | auto | auto refreshes on the interval; manual refreshes only when you ask. |
refreshMinutes | 3 | How often auto mode refreshes, in minutes (at least 1). |
home | empty | The firstmate home to read (~ allowed). Empty: the nearest one at or above the session's working directory. When set, the band shows in every session. |
includeSecondMates | true | Also read the ledgers of the home's local second mate homes (fleet source). |
The GitHub CLI (gh), logged in (gh auth login). The mod reads GitHub only through gh with JSON output. If gh is missing or not logged in, the band shows nothing and the mod adds one dim line to the transcript that says what to do.
Each refresh makes one gh api rate_limit call, then two calls per PR: gh pr view --json for the checks and draft state, and gh api .../actions/runs?head_sha=... for runs held for approval.
claude plugin validate plugins/pr-weather # the plugin and its hooks module
claude plugin test plugins/pr-weather # the weather, ledger, second mate, layout, mode, opening and band tests
npx -p typescript tsc -p plugins/pr-weather # type-check, once a session has loaded the mod
The pure parts (glyphs, check classification, the ledger, the band layout, quota and backoff) are in plugins/pr-weather/hooks/weather.ts, the URL check and opener choice are in plugins/pr-weather/hooks/open.ts, and the data/secondmates.md parser is plugins/pr-weather/hooks/secondmates.ts. The hooks module that calls gh, schedules refreshes and draws the band is plugins/pr-weather/hooks/register.tsx.
hooks/register.tsx 462 lines1// The weather of the fleet's open PRs, one glyph each, in the band above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
4
5import type { Mode, Pr, Status } from '../types'
6import {
7 classifyRollup,
8 formatAge,
9 formatMinutes,
10 glyph,
11 isRateLimited,
12 layoutBand,
13 MAX_BACKOFF_MS,
14 MINUTE,
15 nextBackoff,
16 prsFromLedger,
17 quotaOf,
18 repoOf,
19 terminalDrawsLinks,
20} from './weather'
21import type { Quota, RollupItem } from './weather'
22import { isPrUrl, openerFor, openFailure, pressLogLine } from './open'
23import type { Host, OpenResult, RunResult } from './open'
24import { parseSecondMates } from './secondmates'
25import type { SecondMate } from './secondmates'
26
27const INITIAL: Status = { mode: 'auto', isRefreshing: false, isStale: false, updatedAt: null, backoffMs: null, limitedUntil: null }
28
29const prs = atom({ plugin: 'pr-weather', key: 'prs' } as const, null)
30const status = atom({ plugin: 'pr-weather', key: 'status' } as const, INITIAL)
31const now = atom({ plugin: 'pr-weather', key: 'now' } as const, 0)
32
33// At most one refresh asked for by hand per this long.
34const DEBOUNCE_MS = 30_000
35
36const TIMEOUT_MS = 30_000
37const PR_FIELDS = 'number,url,state,isDraft,headRefOid,statusCheckRollup,mergeable'
38// The records prsFromLedger folds: PR events, and done statuses that name a PR.
39const LEDGER_EVENTS = '"event": *"task\\.(pr_ready|merged|cleaned_up)"|"state": *"done".*https://[^"]*/pull/[0-9]+'
40
41// Nothing to show until the person sets something up; the message says what.
42class SetupNeeded extends Error {}
43
44// GitHub refused for quota; nothing more until `resetAt`, when it is known.
45class RateLimited extends Error {
46 constructor(readonly resetAt?: number) {
47 super('GitHub rate limit')
48 }
49}
50
51type FleetTarget = { source: 'fleet'; home: string; includeSecondMates: boolean }
52type Target = { source: 'mine' } | FleetTarget
53
54type PrView = {
55 number: number
56 url: string
57 state?: string
58 isDraft: boolean
59 headRefOid: string
60 statusCheckRollup?: RollupItem[] | null
61 mergeable?: string
62}
63
64const NO_GH = 'install the GitHub CLI (gh) to see PR weather'
65const NO_LOGIN = 'run `gh auth login` to see PR weather'
66
67async function gh($: EngineInterface, args: string[]): Promise<string> {
68 let result
69 try {
70 result = await $.process.run(['gh', ...args], { timeoutMs: TIMEOUT_MS })
71 } catch (error) {
72 // A timeout rejects too; gh missing is the case where even --version cannot start.
73 const isMissing = await $.process.run(['gh', '--version']).then(() => false, () => true)
74 throw isMissing ? new SetupNeeded(NO_GH) : error
75 }
76 if (result.exitCode === 4) throw new SetupNeeded(NO_LOGIN)
77 if (result.exitCode !== 0 && isRateLimited(result.stderr)) throw new RateLimited()
78 if (result.exitCode !== 0) throw new Error(`gh ${args[0]} exited ${result.exitCode}: ${result.stderr}`)
79 return result.stdout
80}
81
82// Workflow runs on the head commit waiting for someone to approve them.
83async function isHeld($: EngineInterface, url: string, sha: string): Promise<boolean> {
84 const where = repoOf(url)
85 if (!where) return false
86 try {
87 const path = `repos/${where.repo}/actions/runs?head_sha=${sha}&per_page=100`
88 const body = JSON.parse(await gh($, ['api', '--hostname', where.host, path])) as {
89 workflow_runs?: { conclusion?: string | null }[]
90 }
91 return (body.workflow_runs ?? []).some(run => run.conclusion === 'action_required')
92 } catch (error) {
93 // A repository with Actions turned off answers 404: nothing is held there.
94 if (error instanceof Error && !(error instanceof SetupNeeded) && error.message.includes('HTTP 404')) return false
95 throw error
96 }
97}
98
99// gh api rate_limit is free: it does not count against the quota it reports.
100async function readQuota($: EngineInterface): Promise<Quota | null> {
101 return quotaOf(JSON.parse(await gh($, ['api', 'rate_limit'])))
102}
103
104async function toPr($: EngineInterface, view: PrView): Promise<Pr> {
105 return {
106 number: view.number,
107 url: view.url,
108 isDraft: view.isDraft,
109 ci: classifyRollup(view.statusCheckRollup),
110 isHeld: await isHeld($, view.url, view.headRefOid),
111 isConflicting: view.mergeable === 'CONFLICTING',
112 }
113}
114
115// The PR URLs in one home's ledger; null when that home has no ledger.
116async function ledgerUrls($: EngineInterface, home: string): Promise<string[] | null> {
117 const ledger = `${home}/state/fleet-ledger.jsonl`
118 if (!(await $.fs.exists(ledger))) return null
119 // grep rather than $.fs.read: the ledger never rotates and can outgrow one read.
120 const { exitCode, stdout, stderr, isStdoutTruncated } = await $.process.run(['grep', '-E', LEDGER_EVENTS, ledger], { timeoutMs: TIMEOUT_MS })
121 if (exitCode === 1) return []
122 if (exitCode !== 0) throw new Error(`grep exited ${exitCode}: ${stderr}`)
123 // A cut answer would miss the newest records, removals among them.
124 if (isStdoutTruncated) throw new Error(`the PR records of ${ledger} pass 4 MiB`)
125 return prsFromLedger(stdout)
126}
127
128// The second mates registered in the home's data/secondmates.md whose homes exist on this machine.
129async function discoverSecondMates($: EngineInterface, home: string): Promise<SecondMate[]> {
130 const text = await $.fs.read(`${home}/data/secondmates.md`).catch(() => '')
131 const mates = parseSecondMates(text).filter(mate => mate.home !== home)
132 const isHere = await Promise.all(mates.map(mate => $.fs.stat(mate.home).then(stat => stat.kind === 'dir', () => false)))
133 return mates.filter((_, index) => isHere[index])
134}
135
136// The union of the PR URLs in the home's ledger and its second mates' ledgers, each once.
137// A home without a ledger adds a note; the band waits for setup only when no home has one.
138async function fleetUrls($: EngineInterface, target: FleetTarget, notes: string[]): Promise<string[]> {
139 const mates = target.includeSecondMates ? await discoverSecondMates($, target.home) : []
140 const main = await ledgerUrls($, target.home)
141 const lists = [main]
142 for (const mate of mates) {
143 const urls = await ledgerUrls($, mate.home)
144 if (urls === null) {
145 notes.push(`second mate ${mate.id} has no fleet ledger, so its PRs are left out: touch ${mate.home}/config/fleet-ledger`)
146 }
147 lists.push(urls)
148 }
149 const hint = `turn on firstmate's fleet ledger to see the fleet's PRs: touch ${target.home}/config/fleet-ledger`
150 if (lists.every(urls => urls === null)) throw new SetupNeeded(hint)
151 if (main === null) notes.push(hint)
152 return [...new Set(lists.flatMap(urls => urls ?? []))]
153}
154
155// One PR's lookup. A failure other than setup or quota keeps that PR's last good weather, or leaves it out.
156async function isolated(lookup: () => Promise<Pr | null>, last: Pr | undefined): Promise<Pr | null> {
157 try {
158 return await lookup()
159 } catch (error) {
160 if (error instanceof SetupNeeded || error instanceof RateLimited) throw error
161 return last ?? null
162 }
163}
164
165async function collect($: EngineInterface, target: Target, notes: string[], previous: Pr[]): Promise<Pr[]> {
166 const last = new Map(previous.map(pr => [pr.url, pr]))
167 if (target.source === 'mine') {
168 const views = JSON.parse(await gh($, ['pr', 'list', '--author', '@me', '--state', 'open', '--limit', '100', '--json', PR_FIELDS])) as PrView[]
169 const found = await Promise.all(views.map(view => isolated(() => toPr($, view), last.get(view.url))))
170 return found.filter(pr => pr !== null)
171 }
172 const urls = await fleetUrls($, target, notes)
173 const found = await Promise.all(
174 urls.map(url =>
175 isolated(async () => {
176 const view = JSON.parse(await gh($, ['pr', 'view', url, '--json', PR_FIELDS])) as PrView
177 return view.state === 'OPEN' ? toPr($, view) : null
178 }, last.get(url)),
179 ),
180 )
181 return found.filter(pr => pr !== null)
182}
183
184// The deepest directory at or above the session's that holds firstmate's AGENTS.md and a state/.
185async function findHome($: EngineInterface): Promise<string | null> {
186 const found = await $.fs.ancestors({ names: ['AGENTS.md'] })
187 for (const { dir, content } of [...found].reverse()) {
188 if (/^# Firstmate\s*$/m.test(content) && (await $.fs.exists(`${dir}/state`))) return dir
189 }
190 return null
191}
192
193async function resolveTarget(
194 $: EngineInterface,
195 source: string,
196 homeOverride: string,
197 includeSecondMates: boolean,
198 cwd: string,
199): Promise<Target | null> {
200 if (source === 'mine') {
201 const inRepo = await $.process
202 .run(['git', 'rev-parse', '--is-inside-work-tree'], { cwd, timeoutMs: TIMEOUT_MS })
203 .then(({ exitCode }) => exitCode === 0, () => false)
204 return inRepo ? { source: 'mine' } : null
205 }
206 const home = homeOverride || (await findHome($))
207 return home ? { source: 'fleet', home, includeSecondMates } : null
208}
209
210async function expandTilde($: EngineInterface, path: string): Promise<string> {
211 if (path !== '~' && !path.startsWith('~/')) return path
212 return ((await $.env.get('HOME')) ?? '') + path.slice(1)
213}
214
215const OPEN_TIMEOUT_MS = 10_000
216
217async function readHost($: EngineInterface): Promise<Host> {
218 const system = await $.process
219 .run(['uname', '-s'], { timeoutMs: OPEN_TIMEOUT_MS })
220 .then(({ exitCode, stdout }) => (exitCode === 0 ? stdout.trim() : ''), () => '')
221 const isSet = (value: string | undefined) => value !== undefined && value !== ''
222 const isRemote = isSet(await $.env.get('SSH_CONNECTION')) || isSet(await $.env.get('SSH_TTY')) || isSet(await $.env.get('SSH_CLIENT'))
223 const hasDisplay = isSet(await $.env.get('DISPLAY')) || isSet(await $.env.get('WAYLAND_DISPLAY'))
224 return { system, isRemote, hasDisplay }
225}
226
227// Opens the PR in this machine's browser and says so; where there is none to open, or it
228// fails, copies its URL to the clipboard of the surface pressed on and says why.
229// Each step of a press leaves a pressLogLine in the debug log.
230const logPress = ($: EngineInterface, pr: Pr, opener: readonly string[] | null, result: OpenResult) =>
231 $.ui.log(pressLogLine(pr, opener, result), { to: 'debug' })
232
233async function openPr($: EngineInterface, pr: Pr, host: Host, surface: RenderSurface): Promise<void> {
234 const log = (opener: readonly string[] | null, result: OpenResult) => logPress($, pr, opener, result)
235 if (!isPrUrl(pr.url)) {
236 log(null, { kind: 'refused' })
237 $.ui.toast(`PR #${pr.number} has no GitHub URL to open`)
238 return
239 }
240 const opener = openerFor(host, pr.url)
241 let failure: string | null = null
242 if (opener) {
243 const result: RunResult = await $.process.run(opener, { timeoutMs: OPEN_TIMEOUT_MS }).then(
244 ({ exitCode, stderr }) => ({ kind: 'exited', exitCode, stderr }),
245 error => ({ kind: 'threw', error: error instanceof Error ? error.message : String(error) }),
246 )
247 log(opener, result)
248 if (result.kind === 'exited' && result.exitCode === 0) {
249 $.ui.toast(`Opened PR #${pr.number}`)
250 return
251 }
252 failure = openFailure(opener, result)
253 } else {
254 log(null, { kind: 'no-opener' })
255 }
256 const { isCopied } = await $.ui.copy({ text: pr.url, surface })
257 const copied = isCopied ? `copied PR #${pr.number} URL` : pr.url
258 $.ui.toast(failure ? `${failure}; ${copied}` : isCopied ? `Copied PR #${pr.number} URL` : pr.url)
259}
260
261// What the band's buttons and the /pr-weather command drive, for the session's lifetime.
262type Controller = {
263 refreshNow: () => Promise<string>
264 setMode: (mode: Mode) => Promise<string>
265 toggleMode: () => Promise<string>
266 openPr: (pr: Pr, surface: RenderSurface) => Promise<void>
267}
268
269const modeOf = (value: unknown): Mode => (value === 'manual' ? 'manual' : 'auto')
270
271export const register: Register = (on, options) => {
272 const source = options.source === 'mine' ? 'mine' : 'fleet'
273 const configuredHome = String(options.home ?? '').trim()
274 const everyMs = Math.max(1, Number(options.refreshMinutes) || 3) * MINUTE
275 const configMode = modeOf(options.mode)
276 const includeSecondMates = options.includeSecondMates !== false
277 let controller: Controller | null = null
278 let isTerminalLinked = false
279
280 on('session.start', async ($, e, next) => {
281 const started = await next(e)
282 // No one is at the prompt to read the band: poll nothing.
283 if (!e.isInteractive) return started
284 const homeOverride = (await expandTilde($, configuredHome)).replace(/(.)\/+$/, '$1')
285 const target = await resolveTarget($, source, homeOverride, includeSecondMates, e.cwd)
286 if (!target) return started
287 isTerminalLinked = terminalDrawsLinks(await $.env.get('FORCE_HYPERLINK'), await $.env.get('TERM_PROGRAM'))
288
289 // A mode switched from the band is kept until the mode setting itself changes.
290 const stored = (await $.store.get('mode')) as { mode?: unknown; config?: unknown } | undefined
291 const mode = stored?.config === configMode ? modeOf(stored.mode) : configMode
292 await update($, status, s => ({ ...(s ?? INITIAL), mode, isRefreshing: false }))
293 await update($, now, () => 0)
294
295 let timer: Timer | null = null
296 let lastAskedAt = -Infinity
297 let lastHint: string | null = null
298 // Read at the first press, then kept: the machine does not change under a session.
299 let host: Promise<Host> | null = null
300 const noted = new Set<string>()
301 const tick = async () => {
302 const t = await $.clock.now()
303 await update($, now, () => t)
304 return t
305 }
306
307 // Auto mode's next round: after a rate limit resets, else the backed-off or configured interval.
308 const schedule = async () => {
309 timer?.cancel()
310 timer = null
311 const s = await read($, status)
312 if (s.mode !== 'auto') return
313 const t = await $.clock.now()
314 const delay = s.limitedUntil !== null && s.limitedUntil > t ? s.limitedUntil - t : (s.backoffMs ?? everyMs)
315 timer = $.clock.after(delay, () => void refresh())
316 }
317
318 const refresh = async () => {
319 if ((await read($, status)).isRefreshing) return
320 await update($, status, s => ({ ...s, isRefreshing: true }))
321 let quota: Quota | null = null
322 try {
323 quota = await readQuota($)
324 if (quota && quota.remaining === 0) throw new RateLimited(quota.resetAt)
325 const notes: string[] = []
326 const list = await collect($, target, notes, (await read($, prs)) ?? [])
327 for (const note of notes.filter(note => !noted.has(note))) {
328 noted.add(note)
329 $.ui.log(note)
330 }
331 const t = await tick()
332 lastHint = null
333 await update($, prs, () => list)
334 await update($, status, s => ({ ...s, isStale: false, updatedAt: t, limitedUntil: null, backoffMs: nextBackoff(quota, everyMs, s.backoffMs) }))
335 } catch (error) {
336 const t = await tick()
337 if (error instanceof SetupNeeded) {
338 await update($, prs, () => null)
339 await update($, status, s => ({ ...s, isStale: false }))
340 if (lastHint !== error.message) $.ui.log(error.message)
341 lastHint = error.message
342 } else if (error instanceof RateLimited) {
343 const until = error.resetAt ?? quota?.resetAt ?? t + MAX_BACKOFF_MS
344 await update($, status, s => ({ ...s, isStale: true, limitedUntil: Math.max(until, t + MINUTE) }))
345 } else {
346 await update($, status, s => ({ ...s, isStale: true }))
347 }
348 } finally {
349 await update($, status, s => ({ ...s, isRefreshing: false }))
350 await schedule()
351 }
352 }
353
354 const setMode = async (chosen: Mode) => {
355 await $.store.set('mode', { mode: chosen, config: configMode })
356 await update($, status, s => ({ ...s, mode: chosen }))
357 await schedule()
358 return chosen === 'auto' ? `PR weather refreshes every ${formatMinutes(everyMs)}.` : 'PR weather refreshes only when asked.'
359 }
360
361 controller = {
362 refreshNow: async () => {
363 const t = await tick()
364 const s = await read($, status)
365 if (s.isRefreshing) return 'PR weather is already refreshing.'
366 if (s.limitedUntil !== null && s.limitedUntil > t) {
367 return `GitHub rate limit reached; PR weather refreshes again in ${formatMinutes(s.limitedUntil - t)}.`
368 }
369 if (t - lastAskedAt < DEBOUNCE_MS) {
370 return `PR weather refreshed moments ago; try again in ${Math.ceil((lastAskedAt + DEBOUNCE_MS - t) / 1000)}s.`
371 }
372 lastAskedAt = t
373 await refresh()
374 return (await read($, status)).isStale ? 'PR weather refresh failed; showing the last good weather.' : 'PR weather refreshed.'
375 },
376 setMode,
377 // Reads the mode at press time, so two presses before a redraw both flip it.
378 toggleMode: async () => setMode((await read($, status)).mode === 'auto' ? 'manual' : 'auto'),
379 openPr: async (pr, surface) => {
380 logPress($, pr, null, { kind: 'pressed' })
381 await openPr($, pr, await (host ??= readHost($)), surface)
382 },
383 }
384
385 await $.command.register({ name: 'pr-weather', description: 'Refresh PR weather now, or switch it between auto and manual', argumentHint: 'refresh | mode auto|manual' })
386 // The first load runs in either mode; manual mode just never polls after it.
387 $.clock.after(0, () => void refresh())
388 $.clock.every(MINUTE, () => void tick())
389
390 return started
391 })
392
393 on('command.run', { command: 'pr-weather' }, async ($, e) => {
394 if (!controller) return { text: 'PR weather is not active in this session.' }
395 const [verb, arg] = e.args.trim().split(/\s+/)
396 if (verb === 'refresh') return { text: await controller.refreshNow() }
397 if (verb === 'mode' && (arg === 'auto' || arg === 'manual')) return { text: await controller.setMode(arg) }
398 return { text: 'Usage: /pr-weather refresh | /pr-weather mode auto|manual' }
399 })
400
401 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402 const list = await read($, prs)
403 if (e.props.hasSurvey || list === null) return next(e)
404
405 const s = await read($, status)
406 const t = await read($, now)
407 const notes = [
408 s.updatedAt !== null ? ` updated ${formatAge(t - s.updatedAt)}` : '',
409 s.isStale ? ' (stale)' : '',
410 s.limitedUntil !== null && s.limitedUntil > t
411 ? ` rate-limited, resets in ${formatMinutes(s.limitedUntil - t)}`
412 : s.mode === 'auto' && s.backoffMs !== null
413 ? ` low quota, every ${formatMinutes(s.backoffMs)}`
414 : '',
415 ].join('')
416 const refreshLabel = s.isRefreshing ? '…' : '↻'
417 // Each terminal Button draws as "[ label ]", with a space before it.
418 const tail = (list.length === 0 ? ' none'.length : 0) + notes.length + (refreshLabel.length + 5) + (s.mode.length + 5)
419 // The terminal draws its [-] collapse control in the row's last three cells; keep one more of air.
420 const columns = e.props.bodyColumns - (e.surface === 'terminal' ? 4 : 0)
421 const { shown, hidden } = layoutBand(list, columns, tail)
422 const isLinked = e.surface !== 'terminal' || isTerminalLinked
423 const { Box, Button, Link, Text } = $.ui.resolve(e)
424 const below = await next(e)
425
426 return (
427 <Box flexDirection="column">
428 <Box flexDirection="row">
429 <Box key="weather" flexDirection="row">
430 <Text dimColor>PRs</Text>
431 {list.length === 0 && <Text dimColor> none</Text>}
432 {shown.flatMap(pr => {
433 const { mark, color } = glyph(pr)
434 return [
435 <Text key={`glyph:${pr.url}`}>
436 {' '}
437 <Text color={color}>{isLinked ? <Link href={pr.url} label={mark} /> : mark}</Text>{' '}
438 </Text>,
439 <Button
440 key={`pr:${pr.url}`}
441 label={`#${pr.number}`}
442 plain
443 onPress={press => void controller?.openPr(pr, press.surface)}
444 />,
445 ]
446 })}
447 {hidden > 0 && <Text dimColor>{` +${hidden} more`}</Text>}
448 <Text dimColor wrap="truncate">
449 {notes}
450 </Text>
451 </Box>
452 <Text> </Text>
453 <Button key="refresh" label={refreshLabel} hotkey="r" onPress={() => void controller?.refreshNow()} />
454 <Text> </Text>
455 <Button key="mode" label={s.mode} hotkey="m" onPress={() => void controller?.toggleMode()} />
456 </Box>
457 {below}
458 </Box>
459 )
460 })
461}
462hooks/weather.ts 172 lines1// The pure half of pr-weather: classifying gh's answers, folding the fleet
2// ledger, and laying the band out. register.tsx does the I/O around these.
3import type { Ci, Pr } from '../types'
4
5export type Glyph = { mark: string; color: string }
6
7export const GLYPHS = {
8 held: { mark: '↯', color: 'magenta' },
9 failed: { mark: '☂', color: 'red' },
10 conflicting: { mark: '⚔', color: 'red' },
11 running: { mark: '☁', color: 'yellow' },
12 draft: { mark: '✎', color: 'gray' },
13 passed: { mark: '☀', color: 'green' },
14 none: { mark: '-', color: 'gray' },
15} as const satisfies Record<string, Glyph>
16
17// Held for approval > failed > conflicting > running > draft > passed (or no checks).
18export function glyph(pr: Pr): Glyph {
19 if (pr.isHeld) return GLYPHS.held
20 if (pr.ci === 'failed') return GLYPHS.failed
21 if (pr.isConflicting) return GLYPHS.conflicting
22 if (pr.ci === 'running') return GLYPHS.running
23 if (pr.isDraft) return GLYPHS.draft
24 return pr.ci === 'passed' ? GLYPHS.passed : GLYPHS.none
25}
26
27// One statusCheckRollup entry: a CheckRun carries status and conclusion, a
28// StatusContext carries state.
29export type RollupItem = {
30 __typename?: string
31 status?: string | null
32 conclusion?: string | null
33 state?: string | null
34}
35
36const FAILED = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'STARTUP_FAILURE'])
37const RUNNING = new Set(['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED'])
38
39function outcome(item: RollupItem): 'failed' | 'running' | 'passed' {
40 if (item.__typename === 'StatusContext' || (item.status == null && item.state != null)) {
41 const state = (item.state ?? '').toUpperCase()
42 if (FAILED.has(state)) return 'failed'
43 return RUNNING.has(state) ? 'running' : 'passed'
44 }
45 const status = (item.status ?? '').toUpperCase()
46 if (status !== 'COMPLETED') return 'running'
47 return FAILED.has((item.conclusion ?? '').toUpperCase()) ? 'failed' : 'passed'
48}
49
50export function classifyRollup(items: readonly RollupItem[] | null | undefined): Ci {
51 if (!items || items.length === 0) return 'none'
52 const outcomes = items.map(outcome)
53 if (outcomes.includes('failed')) return 'failed'
54 if (outcomes.includes('running')) return 'running'
55 return 'passed'
56}
57
58// A done status that reports a ready PR, and one that reports it landed: fleet-lamp's green rule.
59const READY_PATTERNS = [/PR ready: https:\/\//, /^\s*PR https:\/\//, /child \S+ done: PR https:\/\//]
60const LANDED = /\b(landed|merged)\b/
61const PR_URL = /https:\/\/[^\s"]+\/pull\/\d+/
62
63// The fleet's PR URLs from fleet-ledger.jsonl text (docs/fleet-ledger.md in
64// firstmate): task.pr_ready, or a done task.status that reports a ready PR,
65// sets the task's PR (a later one replaces it); task.merged, task.cleaned_up,
66// or a done status that reports the PR landed or merged drops it. Torn or
67// foreign lines are skipped.
68export function prsFromLedger(text: string): string[] {
69 const byTask = new Map<string, string>()
70 for (const line of text.split('\n')) {
71 let record: { event?: unknown; task?: unknown; pr?: unknown; state?: unknown; text?: unknown }
72 try {
73 record = JSON.parse(line)
74 } catch {
75 continue
76 }
77 if (typeof record?.task !== 'string') continue
78 if (record.event === 'task.pr_ready' && typeof record.pr === 'string') {
79 byTask.set(record.task, record.pr)
80 } else if (record.event === 'task.merged' || record.event === 'task.cleaned_up') {
81 byTask.delete(record.task)
82 } else if (record.event === 'task.status' && record.state === 'done' && typeof record.text === 'string') {
83 const body = record.text
84 const url = PR_URL.exec(body)?.[0]
85 if (!url || !READY_PATTERNS.some(pattern => pattern.test(body))) continue
86 if (LANDED.test(body)) byTask.delete(record.task)
87 else byTask.set(record.task, url)
88 }
89 }
90 return [...new Set(byTask.values())]
91}
92
93// Where a PR URL lives, for the gh api calls about it.
94export function repoOf(url: string): { host: string; repo: string } | null {
95 const match = /^https?:\/\/([^/]+)\/([^/]+\/[^/]+)\/pull\/\d+/.exec(url)
96 return match?.[1] && match[2] ? { host: match[1], repo: match[2] } : null
97}
98
99export const MINUTE = 60_000
100export const MAX_BACKOFF_MS = 30 * MINUTE
101
102// How long ago, as the band says it: "just now", "4m ago", "2h ago".
103export function formatAge(ms: number): string {
104 const minutes = Math.floor(Math.max(0, ms) / MINUTE)
105 if (minutes < 1) return 'just now'
106 if (minutes < 60) return `${minutes}m ago`
107 return `${Math.floor(minutes / 60)}h ago`
108}
109
110// Minutes as the band says them: "12m", "1h", "1h30m".
111export function formatMinutes(ms: number): string {
112 const minutes = Math.max(1, Math.ceil(ms / MINUTE))
113 if (minutes < 60) return `${minutes}m`
114 const rest = minutes % 60
115 return `${Math.floor(minutes / 60)}h${rest ? `${rest}m` : ''}`
116}
117
118// What is left of the GitHub quota, from `gh api rate_limit`: the lowest of the
119// REST (core) and GraphQL buckets gh spends, with that bucket's reset time.
120export type Quota = { ratio: number; remaining: number; resetAt: number }
121
122export function quotaOf(body: unknown): Quota | null {
123 const resources = (body as { resources?: Record<string, unknown> } | null)?.resources
124 let low: Quota | null = null
125 for (const name of ['core', 'graphql']) {
126 const bucket = resources?.[name] as { limit?: unknown; remaining?: unknown; reset?: unknown } | undefined
127 if (typeof bucket?.limit !== 'number' || typeof bucket.remaining !== 'number' || typeof bucket.reset !== 'number') continue
128 if (bucket.limit <= 0) continue
129 const quota = { ratio: bucket.remaining / bucket.limit, remaining: bucket.remaining, resetAt: bucket.reset * 1000 }
130 if (!low || quota.ratio < low.ratio) low = quota
131 }
132 return low
133}
134
135// Below a tenth of the quota, auto mode doubles its wait each round, up to 30
136// minutes (or the configured interval, when that is longer); above it, null.
137export function nextBackoff(quota: Quota | null, baseMs: number, backoffMs: number | null): number | null {
138 if (!quota || quota.ratio >= 0.1) return null
139 return Math.min(Math.max(MAX_BACKOFF_MS, baseMs), (backoffMs ?? baseMs) * 2)
140}
141
142// gh's words for a primary or secondary rate limit.
143export function isRateLimited(stderr: string): boolean {
144 return /rate limit|HTTP 429|abuse detection|submitted too quickly/i.test(stderr)
145}
146
147// Terminals Claude Code draws a Link in as a real hyperlink (OSC 8). Elsewhere
148// it prints the URL after the text, which no one-line band has room for, so
149// the band draws a plain #N there. FORCE_HYPERLINK overrides, as it does for Claude Code.
150const LINKING_TERMINALS = new Set(['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal', 'WezTerm', 'vscode'])
151
152export function terminalDrawsLinks(forceHyperlink: string | undefined, termProgram: string | undefined): boolean {
153 if (forceHyperlink !== undefined) return !(forceHyperlink.length > 0 && Number.parseInt(forceHyperlink, 10) === 0)
154 return termProgram !== undefined && LINKING_TERMINALS.has(termProgram)
155}
156
157const LABEL = 'PRs'
158
159const itemWidth = (pr: Pr) => ` ${glyph(pr).mark} #${pr.number}`.length
160const moreWidth = (hidden: number) => (hidden > 0 ? ` +${hidden} more`.length : 0)
161
162// As many PRs as fit one line of `columns` cells beside a tail of `tail`
163// cells (the time, notes and buttons), the rest counted as +N more.
164export function layoutBand(prs: readonly Pr[], columns: number, tail: number): { shown: Pr[]; hidden: number } {
165 const fixed = LABEL.length + tail
166 const ends = [fixed]
167 for (const pr of prs) ends.push((ends.at(-1) ?? fixed) + itemWidth(pr))
168 let count = prs.length
169 while (count > 0 && (ends[count] ?? 0) + moreWidth(prs.length - count) > columns) count -= 1
170 return { shown: prs.slice(0, count), hidden: prs.length - count }
171}
172hooks/open.ts 64 lines1// How a pressed PR reaches a browser: the machine's own opener, else the clipboard.
2
3// Where the session runs, as the press handler reads it once.
4export type Host = {
5 // `uname -s`: Darwin, Linux, ...; empty when it could not be read.
6 system: string
7 // Reached over SSH: the opener would open a browser on the far machine.
8 isRemote: boolean
9 // A Linux desktop session (X11 or Wayland) an opener can show a browser on.
10 hasDisplay: boolean
11}
12
13// Only a github.com pull request is opened or copied; anything else is refused.
14export function isPrUrl(url: string): boolean {
15 let parsed: URL
16 try {
17 parsed = new URL(url)
18 } catch {
19 return false
20 }
21 // Spelled exactly as rebuilt from its path: no user, port, query or fragment.
22 return /^\/[^/]+\/[^/]+\/pull\/\d+$/.test(parsed.pathname) && url === `https://github.com${parsed.pathname}`
23}
24
25// The command that opens `url` in this machine's browser, or null where none can:
26// a remote session, or a Linux without a desktop (a server reached through a multiplexer).
27export function openerFor(host: Host, url: string): string[] | null {
28 if (host.isRemote) return null
29 if (host.system === 'Darwin') return ['open', url]
30 if (host.system === 'Linux' && host.hasDisplay) return ['xdg-open', url]
31 return null
32}
33
34// How the opener went: it ran and exited, or it could not run (missing, timed out).
35export type RunResult = { kind: 'exited'; exitCode: number; stderr: string } | { kind: 'threw'; error: string }
36
37// How a press went: the press reached the plugin, the opener's run, or no opener to run.
38export type OpenResult = RunResult | { kind: 'pressed' } | { kind: 'no-opener' } | { kind: 'refused' }
39
40// A debug log line of one press, to find with `grep 'pr-weather press'`. Each press logs
41// `pressed` as it arrives, then how the open went:
42// pr-weather press #7 https://github.com/o/r/pull/7 opener=none pressed
43// pr-weather press #7 https://github.com/o/r/pull/7 opener=["open","https://github.com/o/r/pull/7"] exit=0 stderr=""
44export function pressLogLine(pr: { number: number; url: string }, opener: readonly string[] | null, result: OpenResult): string {
45 const outcome =
46 result.kind === 'exited'
47 ? `exit=${result.exitCode} stderr=${JSON.stringify(result.stderr.trim())}`
48 : result.kind === 'threw'
49 ? `error=${JSON.stringify(result.error)}`
50 : result.kind === 'pressed'
51 ? 'pressed'
52 : result.kind === 'refused'
53 ? 'refused=not-a-github-pr-url'
54 : 'copy=no-local-browser'
55 return `pr-weather press #${pr.number} ${pr.url} opener=${opener ? JSON.stringify(opener) : 'none'} ${outcome}`
56}
57
58// Why the opener did not open the PR, in a few words for a toast: "open exited 1", "open failed: spawn open ENOENT".
59export function openFailure(opener: readonly string[], result: RunResult): string {
60 const name = opener[0] ?? 'opener'
61 if (result.kind === 'exited') return `${name} exited ${result.exitCode}`
62 return `${name} failed: ${result.error.length > 40 ? `${result.error.slice(0, 39)}…` : result.error}`
63}
64hooks/secondmates.ts 24 lines1// The second mate homes a firstmate home has registered in data/secondmates.md.
2// Pure, so the tests hold it without a file system.
3export type SecondMate = { id: string; home: string }
4
5// A local entry is `- <id> - <charter> (home: <path>; scope: ...)`; a remote one puts
6// `host: ...; root: ...;` before its home, which lives on another machine.
7const ENTRY = /^- ([A-Za-z0-9._-]+) - .+ \((host:[^;)]*;\s*root:[^;)]*;\s*)?home:\s*([^;)]*);.*\)\s*$/
8
9const trimSlash = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path)
10
11/** The local second mates in secondmates.md text, in file order; remote and malformed entries are left out. */
12export function parseSecondMates(text: string): SecondMate[] {
13 const found: SecondMate[] = []
14 for (const line of text.split('\n')) {
15 const match = ENTRY.exec(line.trim())
16 const home = trimSlash((match?.[3] ?? '').trim())
17 if (!match?.[1] || match[2] !== undefined || !home.startsWith('/')) continue
18 if (found.some(mate => mate.home === home)) continue
19 found.push({ id: match[1], home })
20 }
21 return found
22}
23
24types/index.d.ts 36 lines1// A PR's CI, read off gh's statusCheckRollup.
2export type Ci = 'passed' | 'failed' | 'running' | 'none'
3
4export type Pr = {
5 number: number
6 url: string
7 isDraft: boolean
8 ci: Ci
9 // A workflow run on the head commit waits at conclusion action_required.
10 isHeld: boolean
11 // GitHub's mergeable is CONFLICTING; UNKNOWN (still computing) is not a conflict.
12 isConflicting: boolean
13}
14
15// auto polls on the interval; manual refreshes only when asked.
16export type Mode = 'auto' | 'manual'
17
18// How the refreshing stands; times are $.clock.now() milliseconds.
19export type Status = {
20 mode: Mode
21 isRefreshing: boolean
22 // The last refresh failed; the PRs shown are from an earlier one.
23 isStale: boolean
24 updatedAt: number | null
25 // Auto mode's stretched interval while the GitHub quota is low.
26 backoffMs: number | null
27 // A rate limit holds every refresh until then.
28 limitedUntil: number | null
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'pr-weather': { prs: Pr[] | null; status: Status; now: number }
34 }
35}
36