SLOPSHOPPER

pr-weather

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

newbandcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
README

firstmate-mods

<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.

ModWhat it does
fleet-lampA lamp above the prompt: red when the fleet needs you, green when a PR is ready.
pr-weatherThe CI weather of the fleet's open PRs above the prompt: one glyph per PR.

Install

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

fleet-lamp

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:

The fleet-lamp band in red: a red dot, then "fix-login needs-decision: keep session cookie (A) or move to JWT (B)?  +1 more"

A PR is ready for review (green):

The fleet-lamp band in green: a green dot, then "PR ready <a href=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.

What it shows

  • 🔴 Red (strongest): a task is waiting on you. The band shows the task, its state and its reason. The reason is the status text without the status line's own lead (the state word, any [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.
  • 🟢 Green: a PR is ready for review. The band shows the PR URL and its task.
  • Nothing: the fleet doesn't need you.

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 rules

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.

  • Red: a task.status record whose state is needs-decision, blocked or failed. Two kinds of record are left out:
  • worker validation findings that firstmate decides itself (text contains ask-user findings=);
  • a held decision recorded again with the captain's own answer (Captain 2026-09-30 verbatim ...).
  • A keyed red (a record with a [key=...] decision key) turns off by itself when a later resolved record carries the same task and key.
  • A keyless red stays until your next prompt.
  • Green (latched): a 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.
  • Your next prompt clears the band, red and green alike. Only a prompt you send counts, typed at the terminal or sent through Remote Control. Task notifications, messages from other sessions and scheduled prompts leave the band as it is.

These are the same rules as the lamp script that inspired this mod.

Turning on the ledger

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.

How it finds your fleet

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

Second mates

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.

How it reads the ledger

  • The mod checks the ledger every 2 seconds and reads only the lines added since the last check, from a saved byte offset. A partial last line waits for the next check.
  • On the first look at the ledger in a session, the mod starts at its end, so old history does not turn the band red.
  • The offset and the signals are kept in the session's state, so a reload of the mod does not read old lines again.
  • If the ledger is truncated, the mod reads it again from the top.
  • The ledger never rotates, and $.fs.read reads whole files of at most 4 MiB. So the mod reads the new bytes with tail -c +<offset>.

Developing

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.

pr-weather

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 ]

What it shows

GlyphMeaning
↯ (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;
  • the refresh button [ ↻ ] ([ … ] while a refresh runs) and the mode button [ auto ] or [ manual ].

Opening a PR

Press a PR's #N to open it, in any terminal:

  • Fullscreen layout: click #N.
  • Default layout: focus the band with ctrl+x tab, move to #N with tab, and press Enter.
  • Desktop: click #N.

What a press does depends on where the session runs:

  • A local Mac runs open <url>, which opens the PR in your default browser, and shows Opened PR #N.
  • A local Linux desktop (with DISPLAY or WAYLAND_DISPLAY set) runs xdg-open <url> and shows Opened PR #N.
  • Anywhere else, the mod copies the PR URL to your clipboard and shows 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.
  • If the opener fails, the mod copies the URL instead, and the toast says why first: 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.

Refreshing

  • auto (default): the mod refreshes on the interval, every 3 minutes unless you change it.
  • manual: the mod loads once when the session starts and then refreshes only when you ask.

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.

Rate limits

Each refresh first reads your remaining GitHub quota with gh api rate_limit, which does not count against the quota.

  • Below 10% remaining (of the REST or GraphQL quota, whichever is lower), auto mode doubles its interval each refresh, up to 30 minutes, and the band says so. When the quota recovers, the interval returns to normal.
  • When the quota is used up, or GitHub answers a call with a rate-limit error, the band is marked stale and no refresh runs, by hand or on the interval, until the quota resets.

Which PRs

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):

  • a task.pr_ready record adds its PR, and a later one for the same task replaces it;
  • a 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://...);
  • a 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;
  • a PR that GitHub reports as merged or closed is left out.

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.

Settings

Set these in /config, or from a shell:

echo '{"mode": "manual", "refreshMinutes": 5}' | claude plugin configure pr-weather@firstmate-mods --values-stdin
SettingDefaultWhat it does
sourcefleetfleet: the fleet ledger's PRs, in firstmate sessions. mine: your open PRs in the session's repository.
modeautoauto refreshes on the interval; manual refreshes only when you ask.
refreshMinutes3How often auto mode refreshes, in minutes (at least 1).
homeemptyThe 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.
includeSecondMatestrueAlso read the ledgers of the home's local second mate homes (fleet source).

Requirements

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.

Developing

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.

Source 5 files
hooks/register.tsx 462 lines
1// 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}
462
hooks/weather.ts 172 lines
1// 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}
172
hooks/open.ts 64 lines
1// 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}
64
hooks/secondmates.ts 24 lines
1// 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
24
types/index.d.ts 36 lines
1// 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