SLOPSHOPPER

gh-pane

A docked pane of the session repo's issues and PRs in run order, with buttons that fill the next command, plus two for this session's ship runs: send the merge…

newpanebandguardcommandtoast
v0.6.0no licenseupdated 2026-10-07Gharib89/claude-mods/plugins/gh-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-pane
│ ┃ gh-pane ✕ › fix the failing auth test and add an audit log call │ ┃ gh-pane: JSON Parse error: Unexpected EOF [ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gh-pane │ ⎿ gh-pane: gh-pane opened. │ │ ⟨Claude Code's own drawing⟩ Hide gh-pane  ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Hide gh-pane 
Pane · gh-pane
gh-pane: JSON Parse error: Unexpected EOF [ retry ]
README

claude-mods

Claude Code mods (function-hook plugins), installed through one marketplace.

Mods

  • smart-compact: lets Claude compact its own context at a step boundary it picks, with its own instructions, then resume the work on its own. Reminders arrive at 60% and 80% of the auto-compact point. Interactive sessions only: a -p or SDK session cannot compact on request yet, so there the mod only adds its keep-list to automatic compactions.
  • gh-pane: /gh-pane, or the Open gh-pane button above the prompt (Hide gh-pane while the pane is up, each followed by the Nerd Font GitHub mark, which /plugin configure can blank where the terminal font has none), docks a pane of the session repo's open issues and PRs in run order: spec trees from sub-issues, blocked-by edges, claims and their ship runs, planning maps whose tickets close without a PR, and the needs-triage and needs-info lists. Its buttons fill the prompt with /ship N, /triage N, a map's next ticket (/wayfinder MAP N), a request to release a stale claim, or a request to close an issue whose sub-issues are all closed (all done), and never send it. Two band buttons act on this session's own state instead: when a main-loop answer carries the merge-gate text and a PR link, merge PR #N (hotkey m) sends the merge reply as your own prompt, and once that PR reads as merged over REST, clear + /ship N (hotkey n) clears the session and runs the first ready row's command (one that does not start with / shows no button), and goes once the next turn ends. When a ship run merges on its own on a clean gate, its answer's merged line (Merged on a clean gate: with the PR link after it on that line) takes the same read, so clear + /ship N shows with no merge button. A hotkey fires only once the band holds focus (ctrl+x then tab, or a click), never from the prompt, where m types a letter; a click always works. A /clear keeps the open pane's last read of the same folder on screen until the next one lands. It reads the repo through git and gh api (REST only), so gh must be installed and signed in. The label names (the map label included), PR cap, ship worktree layout, the band button's icon, the text each button fills, the merge-gate text, the merged text and the merge reply are options in /plugin configure gh-pane@claude-mods.

Install

claude plugin marketplace add Gharib89/claude-mods
claude plugin install smart-compact@claude-mods
claude plugin install gh-pane@claude-mods
Source 4 files
hooks/register.tsx 438 lines
1// gh-pane: `/gh-pane`, or the band's button above the prompt, docks a pane of the session repo's open issues and PRs
2// in run order. Its buttons fill the prompt with the next command and never send it: the person reads it and presses
3// Enter. Two band buttons act on this session's own state instead: at its own merge gate one sends the merge reply,
4// and once the PR is merged, there or by a ship run on its own, one clears the session and runs the next command.
5
6import { atom, read, update } from 'claude-code'
7import type { Elements, EngineInterface, PluginOptions, Register } from 'claude-code'
8
9import type { Gate, Issue, Snapshot } from '../types'
10import { isMerged, readBacklog, type Runner } from './github'
11import { commandOf, fill, gatePr, mergedPrOf, runLine } from './rules'
12
13const PANE = 'gh-pane'
14const snapshot = atom({ plugin: 'gh-pane', key: 'snapshot' } as const, null)
15const gate = atom({ plugin: 'gh-pane', key: 'gate' } as const, null)
16
17type E = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link'>
18type Config = ReturnType<typeof configOf>
19
20const configOf = (o: PluginOptions) => ({
21  needsTriage: String(o.needsTriageLabel),
22  needsInfo: String(o.needsInfoLabel),
23  readyForAgent: String(o.readyForAgentLabel),
24  readyForHuman: String(o.readyForHumanLabel),
25  mapLabel: String(o.mapLabel),
26  mapCommand: String(o.mapCommand),
27  prCap: Number(o.prCap),
28  worktreeLayout: String(o.worktreeLayout),
29  shipCommand: String(o.shipCommand),
30  triageCommand: String(o.triageCommand),
31  releaseRequest: String(o.releaseRequest),
32  closeRequest: String(o.closeRequest),
33  gateText: String(o.gateText),
34  mergedText: String(o.mergedText),
35  mergeReply: String(o.mergeReply),
36  buttonIcon: String(o.buttonIcon),
37})
38
39const cut = (text: string, room: number) => (text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`)
40
41const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)).split('\n')[0]
42
43// Reads overlap (a timer, a gh call, the command): only the latest one started may land.
44let reads = 0
45// The last snapshot landed, which the pane draws while `$.state` holds none; the state copy stays because its write
46// redraws the pane. A /clear empties `$.state` while the pane stays up, and its one hook, `session.end`, runs before
47// that, so a read started there lands in the old session (seen on 2.1.289). This module variable outlives the /clear,
48// and draws only in the folder it was read in: one process may host more than one session.
49let kept: { cwd: string; view: Snapshot } | null = null
50
51const runnerOf = ($: EngineInterface): Runner => async argv => {
52  const { exitCode, stdout, stderr } = await $.process.run(argv).catch((error: unknown) => {
53    throw new Error(`${argv[0]} did not run (not installed, or timed out): ${messageOf(error)}`)
54  })
55  if (exitCode !== 0) throw new Error(`${argv[0]} ${argv[1]}: ${stderr.trim().split('\n')[0]}`)
56  return stdout
57}
58
59// Answers the snapshot it read, landed or not: a read overtaken by a newer one is still fresh for its caller.
60async function refresh($: EngineInterface, config: Config): Promise<Snapshot> {
61  const ticket = ++reads
62  const cwd = await $.session.cwd()
63  const run = runnerOf($)
64  const now = new Date(await $.clock.now()).toISOString()
65  const next = await readBacklog(run, config.worktreeLayout, now).catch((error: unknown) => ({ error: `gh-pane: ${messageOf(error)}` }))
66  if (ticket !== reads) return next
67  kept = { cwd, view: next }
68  await update($, snapshot, () => next)
69  return next
70}
71
72// Opened by the person (the command, the band's button), the pane is placed at any width. The band draws from
73// `$.ui.panes()`, which no redraw follows (2.1.289), so show, hide and the ui.close hook each invalidate it.
74async function show($: EngineInterface, config: Config) {
75  const opened = await $.ui.open({ id: PANE, title: 'gh-pane' })
76  $.ui.invalidate('ui.render')
77  await refresh($, config)
78  return opened
79}
80
81// The mod's own $.ui.close skips its own ui.close hook (seen in `claude plugin test` on 2.1.289), so hide
82// invalidates the band itself.
83async function hide($: EngineInterface) {
84  await $.ui.close({ id: PANE })
85  $.ui.invalidate('ui.render')
86}
87
88async function isOpen($: EngineInterface) {
89  return (await $.ui.panes()).some(pane => pane.id === PANE)
90}
91
92// Nothing awaits a timer tick or a re-read after a tool call, so a failure there is toasted rather than dropped.
93async function refreshIfOpen($: EngineInterface, config: Config) {
94  try {
95    if (await isOpen($)) await refresh($, config)
96  } catch (error) {
97    $.ui.toast(`gh-pane did not refresh: ${messageOf(error)}`)
98  }
99}
100
101async function propose($: EngineInterface, text: string) {
102  const draft = (await $.prompt.read()).text.trim()
103  await $.prompt.fill(draft === '' ? { text } : { text: ` ${text}`, mode: 'append' })
104  $.ui.toast(`In the prompt: ${text}  (Enter sends it)`)
105}
106
107/**
108 * A row's state, first match wins. An issue whose sub-issues are all closed waits only on its own close. A map ticket
109 * (a sub-issue of a map) left open, unblocked, unclaimed and waiting on neither triage nor info is next.
110 */
111function stateOf(
112  issue: Issue,
113  config: Config,
114  isMapTicket: boolean,
115): { tag: string; color: string; pr?: number; blockers?: Issue['blockers']; isReady?: true; isNext?: true; isDone?: true } {
116  if (issue.pr !== undefined) return { tag: 'in PR ', color: 'cyan', pr: issue.pr }
117  if (issue.subs.total > 0 && issue.subs.done === issue.subs.total) return { tag: 'all done', color: 'green', isDone: true }
118  if (issue.blockers.length > 0) return { tag: 'blocked by ', color: 'yellow', blockers: issue.blockers }
119  if (issue.assignees.length > 0) return { tag: `claimed (${issue.assignees.join(', ')})`, color: 'blue' }
120  if (issue.labels.includes(config.readyForAgent)) return { tag: 'ready now', color: 'green', isReady: true }
121  if (issue.labels.includes(config.readyForHuman)) return { tag: 'yours', color: 'magenta' }
122  const waiting = issue.labels.find(l => l === config.needsTriage || l === config.needsInfo)
123  if (waiting !== undefined) return { tag: waiting, color: 'gray' }
124  if (issue.labels.includes(config.mapLabel)) return { tag: 'map', color: 'gray' }
125  if (isMapTicket) return { tag: 'ready now', color: 'green', isNext: true }
126  return { tag: 'untriaged', color: 'gray' }
127}
128
129type Open = Exclude<Snapshot, { error: string }>
130
131const has = (issue: Issue, label: string) => issue.labels.includes(label)
132
133/** The pane's order: each root's tree, then the lone issues waiting on triage or info. */
134function arrange(view: Open, config: Config) {
135  const byNumber = new Map(view.issues.map(issue => [issue.number, issue]))
136  // A map and its tickets at every depth resolve by a closing comment, never a PR.
137  const mapOf = (issue: Issue): number | undefined =>
138    has(issue, config.mapLabel) ? issue.number : issue.parent === undefined ? undefined : mapOf(byNumber.get(issue.parent)!)
139  const isMapTicket = (issue: Issue) => mapOf(issue) !== undefined && !has(issue, config.mapLabel)
140  // A spec tree draws in place whatever its root's label; a lone issue waiting on triage or info waits below.
141  const top = view.issues.filter(issue => issue.parent === undefined)
142  const isWaiting = (issue: Issue, label: string) => issue.subs.total === 0 && has(issue, label)
143  const roots = top.filter(issue => !isWaiting(issue, config.needsTriage) && !isWaiting(issue, config.needsInfo))
144  const waiting = [
145    { name: 'needs triage', issues: top.filter(issue => isWaiting(issue, config.needsTriage)), canTriage: true },
146    { name: 'needs info', issues: top.filter(issue => isWaiting(issue, config.needsInfo)), canTriage: false },
147  ]
148  return { byNumber, mapOf, isMapTicket, roots, waiting }
149}
150
151/** The command of the first row in the pane's order whose button starts work: a ready issue's ship or a map's next. */
152function nextCommand(view: Open, config: Config): string | undefined {
153  const { byNumber, mapOf, isMapTicket, roots, waiting } = arrange(view, config)
154  const walk = (issue: Issue): Issue[] => [issue, ...issue.children.flatMap(n => walk(byNumber.get(n)!))]
155  for (const issue of [...roots.flatMap(walk), ...waiting.flatMap(group => group.issues)]) {
156    const state = stateOf(issue, config, isMapTicket(issue))
157    if (state.isReady) return fill(config.shipCommand, { n: issue.number })
158    if (state.isNext) return fill(config.mapCommand, { map: mapOf(issue)!, n: issue.number })
159  }
160  return undefined
161}
162
163const setGate = ($: EngineInterface, to: Gate | null) => update($, gate, () => to)
164
165// Sends the reply as the person's own words. The press marks the merging before the send, so the state is right
166// whether or not the mod's own prompt.submit hook sees this call.
167async function pressMerge($: EngineInterface, config: Config, at: Gate) {
168  await setGate($, { ...at, phase: 'merging' })
169  try {
170    await $.prompt.submit({ text: config.mergeReply, asUser: true })
171  } catch (error) {
172    await setGate($, at)
173    throw error
174  }
175}
176
177// The merge reply's turn, or one carrying the merged text, ended: the PR itself says whether it merged (a stale-base
178// or a no leaves it open), and a fresh read, pane open or not, picks the next command.
179async function settleMerge($: EngineInterface, config: Config, pr: number) {
180  try {
181    if (!(await isMerged(runnerOf($), pr))) return await setGate($, null)
182    const view = await refresh($, config)
183    if ('error' in view) $.ui.toast(view.error)
184    const text = 'error' in view ? undefined : nextCommand(view, config)
185    const run = text === undefined ? undefined : commandOf(text)
186    await setGate($, { pr, phase: 'merged', next: text === undefined || run === undefined ? undefined : { text, ...run } })
187  } catch (error) {
188    await setGate($, null)
189    $.ui.toast(`gh-pane could not read PR #${pr}: ${messageOf(error)}`)
190  }
191}
192
193// Seen on 2.1.291: `$.command.run` works from a button press but is refused inside a `command.run` hook, no
194// `session.start` fires after a /clear, and `$.prompt.submit` refuses text starting with `/`. So the next button runs
195// the whole chain inside one press: /clear, then the command as a command.
196async function pressNext($: EngineInterface, next: NonNullable<Gate['next']>) {
197  await setGate($, null)
198  await $.command.run({ command: 'clear' })
199  await $.command.run({ command: next.command, args: next.args })
200}
201
202export const register: Register = (on, options) => {
203  const config = configOf(options)
204
205  // A main-loop answer carrying the gate text puts this session at the gate, and one carrying the merged text says a
206  // ship run merged on its own, with no gate to answer; a subagent's turn is neither.
207  on('turn.complete', async ($, e, next) => {
208    const done = await next(e)
209    if (e.agentId !== undefined) return done
210    const mergedPr = mergedPrOf(e.answer, config.mergedText)
211    const pr = gatePr(e.answer, config.gateText)
212    if (mergedPr !== undefined) void settleMerge($, config, mergedPr)
213    else if (pr !== undefined) await setGate($, { pr, phase: 'gate' })
214    else {
215      const at = await read($, gate)
216      if (at?.phase === 'merging') void settleMerge($, config, at.pr)
217      // The next command was picked at merge time: it stands for the turn that follows the merge, no longer.
218      else if (at?.phase === 'merged') await setGate($, null)
219    }
220    return done
221  })
222
223  // A typed reply: the merge word counts as the button's press, anything else hides the button until the gate returns.
224  on('prompt.submit', async ($, e, next) => {
225    const at = await read($, gate)
226    if (at?.phase === 'gate') await setGate($, e.text.trim() === config.mergeReply ? { ...at, phase: 'merging' } : null)
227    return next(e)
228  })
229
230  on('session.end', async ($, e, next) => {
231    await setGate($, null)
232    return next(e)
233  })
234
235  on('session.start', async ($, e, next) => {
236    await $.command.register({
237      name: 'gh-pane',
238      description: "Open a pane of this repo's issues and PRs in run order",
239      // Typed mid-turn, the pane opens at once instead of waiting for the turn to end.
240      immediate: true,
241    })
242    $.clock.every(120_000, () => refreshIfOpen($, config))
243    return next(e)
244  })
245
246  on('command.run', { command: 'gh-pane' }, async $ => {
247    const opened = await show($, config)
248    return { text: opened.isPlaced ? 'gh-pane opened.' : `gh-pane is waiting: ${opened.reason}` }
249  })
250
251  // Closed by the person's close mark; an unload runs none of the opener's hooks.
252  on('ui.close', { id: PANE }, async ($, e, next) => {
253    const closed = await next(e)
254    $.ui.invalidate('ui.render')
255    return closed
256  })
257
258  // One button above the prompt that shows or hides the pane; whatever another plugin draws there stays above it.
259  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260    const below = await next(e)
261    if (e.props.hasSurvey) return below
262    const { Box, Text, Button }: E = $.ui.resolve(e)
263    // An unreadable pane list or gate reads as closed: a throw here would take the band of every plugin beneath with it.
264    const shown = await isOpen($).catch(() => false)
265    const at = await read($, gate).catch(() => null)
266    const upNext = at?.phase === 'merged' ? at.next : undefined
267    const toast = (error: unknown) => $.ui.toast(`gh-pane: ${messageOf(error)}`)
268    return (
269      <Box flexDirection="column">
270        {below}
271        <Box gap={2}>
272          <Button
273            key="toggle"
274            plain
275            label={`${shown ? 'Hide' : 'Open'} gh-pane ${config.buttonIcon}`.trim()}
276            onPress={() => (shown ? hide($) : show($, config)).catch(toast)}
277          />
278          {at?.phase === 'gate' && <Button key="merge" hotkey="m" label={`merge PR #${at.pr}`} onPress={() => pressMerge($, config, at).catch(toast)} />}
279          {at?.phase === 'merging' && <Text color="cyan">{`merging PR #${at.pr}…`}</Text>}
280          {upNext && <Button key="next" hotkey="n" label={`clear + ${upNext.text}`} onPress={() => pressNext($, upNext).catch(toast)} />}
281        </Box>
282      </Box>
283    )
284  })
285
286  // A gh call or a push moves issues and PRs: re-read once it has run. Each word counts only where a command starts
287  // or follows a space or shell operator, and git's global options (`-C <path>`, `-c <key=value>`, `--no-pager`) may
288  // sit before `push`.
289  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
290    const ran = await next(e)
291    if (/(?:^|[\s;&|(])(?:gh\s|git\s+(?:-[Cc]\s+\S+\s+|-\S+\s+)*push\b)/.test(e.command)) void refreshIfOpen($, config)
292    return ran
293  })
294
295  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
296    const { Box, Text, Button, Link }: E = $.ui.resolve(e)
297    const view = (await read($, snapshot)) ?? (kept?.cwd === (await $.session.cwd()) ? kept.view : null)
298    const room = e.props.bodyColumns
299    if (view === null) return <Text dimColor>Reading the repo over gh api…</Text>
300    // A failed read (a network blip) holds the pane until the next re-read, so retry starts one now.
301    if ('error' in view) {
302      return (
303        <Box gap={1}>
304          <Text color="red">{cut(view.error, room - 10)}</Text>
305          <Button key="retry" label="retry" onPress={() => refreshIfOpen($, config)} />
306        </Box>
307      )
308    }
309    if (view.issues.length === 0 && view.prs === 0) return <Text dimColor>{`Nothing open in ${view.repo}.`}</Text>
310
311    const { byNumber, mapOf, isMapTicket, roots, waiting } = arrange(view, config)
312    const ref = (n: number, kind: 'issues' | 'pull' = 'issues') => (
313      <Link href={(kind === 'issues' && byNumber.get(n)?.url) || `https://github.com/${view.repo}/${kind}/${n}`} label={`#${n}`} />
314    )
315    const blockers = (list: Issue['blockers']) =>
316      list.flatMap(({ label, url }, i) => [...(i === 0 ? [] : [', ']), <Link key={label} href={url} label={label} />])
317    const runs = new Map(
318      view.issues
319        .filter(issue => issue.assignees.length > 0)
320        .map(issue => [issue.number, runLine({ ...issue, now: view.fetchedAt, hasPr: issue.pr !== undefined, expectsPr: mapOf(issue) === undefined })] as const),
321    )
322    const lines = [...runs.values()].filter(line => line !== null)
323    const stale = lines.filter(line => line.isStale).length
324    // Ready now: every row with a button that starts work, a ship or a map's next.
325    const ready = view.issues.filter(issue => {
326      const state = stateOf(issue, config, isMapTicket(issue))
327      return state.isReady || state.isNext
328    }).length
329    const isCapFull = view.prs >= config.prCap
330
331    const triage = (issues: number[]) =>
332      fill(config.triageCommand, { issues: issues.length === 1 ? `${issues[0]}` : `${issues.map(n => `#${n}`).join(', ')} one by one` })
333
334    const row = (issue: Issue, prefix: string) => {
335      const state = stateOf(issue, config, isMapTicket(issue))
336      const bar = '█'.repeat(Math.round((issue.subs.done / Math.max(1, issue.subs.total)) * 8)).padEnd(8, '░')
337      const run = runs.get(issue.number)
338      const pad = `${prefix.replace('├', '│').replace(/[└•▾]/, ' ')}  ↳`
339      return [
340        <Box key={`row-${issue.number}`} gap={1}>
341          <Text dimColor>{prefix}</Text>
342          <Text bold={state.isReady === true}>
343            {ref(issue.number)} {cut(issue.title, Math.max(8, room - prefix.length - 36))}
344          </Text>
345          {issue.subs.total > 0 && <Text dimColor>{`${bar} ${issue.subs.done}/${issue.subs.total}`}</Text>}
346          <Text color={state.color}>
347            {state.tag}
348            {state.pr !== undefined ? ref(state.pr, 'pull') : ''}
349            {state.blockers ? blockers(state.blockers) : ''}
350          </Text>
351          {state.isReady && (
352            <Button key={`ship-${issue.number}`} plain label="ship" onPress={() => propose($, fill(config.shipCommand, { n: issue.number }))} />
353          )}
354          {state.isNext && (
355            <Button
356              key={`next-${issue.number}`}
357              plain
358              label="next"
359              onPress={() => propose($, fill(config.mapCommand, { map: mapOf(issue)!, n: issue.number }))}
360            />
361          )}
362          {state.isDone && (
363            <Button
364              key={`close-${issue.number}`}
365              plain
366              label="close"
367              onPress={() => propose($, fill(config.closeRequest, { n: issue.number, total: issue.subs.total }))}
368            />
369          )}
370          {has(issue, config.needsTriage) && (
371            <Button key={`triage-${issue.number}`} plain label="triage" onPress={() => propose($, triage([issue.number]))} />
372          )}
373        </Box>,
374        run && (
375          <Box key={`run-${issue.number}`} gap={1}>
376            <Text dimColor>{pad}</Text>
377            <Text color={run.isStale ? 'red' : 'cyan'} bold={run.isStale}>
378              {cut(run.text, Math.max(12, room - pad.length - 12))}
379            </Text>
380            {run.isStale && (
381              <Button
382                key={`release-${issue.number}`}
383                plain
384                label="release"
385                onPress={() => propose($, fill(config.releaseRequest, { n: issue.number, user: issue.assignees.join(', ') }))}
386              />
387            )}
388          </Box>
389        ),
390      ]
391    }
392
393    // Each sub-issue below `issue`, at every depth.
394    const branch = (issue: Issue, indent: string): ReturnType<typeof row> => [
395      issue.subs.done > 0 ? <Text key={`done-${issue.number}`} dimColor>{`${indent}├ ✓ ${issue.subs.done} done`}</Text> : undefined,
396      ...issue.children.flatMap((n, i) => {
397        const isLast = i === issue.children.length - 1
398        const child = byNumber.get(n)!
399        return [...row(child, `${indent}${isLast ? '└' : '├'}`), ...branch(child, `${indent}${isLast ? ' ' : '│'} `)]
400      }),
401    ]
402
403    return (
404      <Box flexDirection="column" width={room}>
405        <Box gap={1}>
406          <Text color={isCapFull ? 'yellow' : undefined} dimColor={!isCapFull}>
407            {`PR cap ${view.prs}/${config.prCap} · ready now: ${ready} · runs: ${lines.length - stale} active · ${stale} stale`}
408          </Text>
409          <Button key="refresh" plain label="↻" onPress={() => refreshIfOpen($, config)} />
410        </Box>
411        {roots.map(root =>
412          root.subs.total === 0 ? (
413            row(root, '•')
414          ) : (
415            <Box key={`tree-${root.number}`} flexDirection="column" marginTop={1}>
416              {row(root, '▾')}
417              {branch(root, '  ')}
418            </Box>
419          ),
420        )}
421        {waiting.map(({ name, issues, canTriage }) =>
422          issues.length === 0 ? null : (
423            <Box key={name} flexDirection="column" marginTop={1}>
424              <Box gap={1}>
425                <Text bold color="gray">{`${name} (${issues.length})`}</Text>
426                {canTriage && issues.length > 1 && (
427                  <Button key="triage-all" plain label="triage all" onPress={() => propose($, triage(issues.map(issue => issue.number)))} />
428                )}
429              </Box>
430              {issues.flatMap((issue, i) => row(issue, i === issues.length - 1 ? '  └' : '  ├'))}
431            </Box>
432          ),
433        )}
434      </Box>
435    )
436  })
437}
438
hooks/github.ts 81 lines
1// Reads the session repo's open issues and PRs over GitHub REST (`gh api`), never GraphQL, which flakes 401
2// mid-session (docs/adr/0001-mods-may-read-the-session-repo.md).
3
4import type { Issue, Snapshot } from '../types'
5import { closesOf, worktreeIssue } from './rules'
6
7/** Runs argv on the host and answers its stdout, throwing on a nonzero exit; register.tsx owns `$`. */
8export type Runner = (argv: string[]) => Promise<string>
9
10type Raw = Record<string, any>
11
12// Every path read here answers a list; --slurp wraps each page in an outer array.
13const list = async (run: Runner, path: string): Promise<Raw[]> =>
14  (JSON.parse(await run(['gh', 'api', '--paginate', '--slurp', path])) as Raw[][]).flat()
15
16/** Runs `tasks` at most 8 at a time: GitHub's secondary rate limit refuses many concurrent requests. */
17async function inTurn(tasks: (() => Promise<void>)[]) {
18  const queue = [...tasks]
19  await Promise.all(Array.from({ length: 8 }, async () => {
20    for (let task = queue.shift(); task !== undefined; task = queue.shift()) await task()
21  }))
22}
23
24/** The open backlog, `layout` matching each claimed issue to its ship worktree. */
25export async function readBacklog(run: Runner, layout: string, now: string): Promise<Snapshot> {
26  // gh fills `{owner}/{repo}` from the checkout's remotes, and with none on a GitHub host fails in its own words.
27  const repo = (await run(['gh', 'api', 'repos/{owner}/{repo}', '--jq', '.full_name'])).trim()
28  // The first worktree listed is the main checkout.
29  const [main = '', ...paths] = [...(await run(['git', 'worktree', 'list', '--porcelain'])).matchAll(/^worktree (.+)$/gm)].map(m => m[1]!)
30  const worktrees = new Map(paths.map(path => [worktreeIssue(layout, main, path), path.slice(path.lastIndexOf('/') + 1)]))
31
32  const open = await list(run, `repos/${repo}/issues?state=open&per_page=100`)
33  const prs = open.filter(raw => raw.pull_request)
34  const closer = new Map(prs.flatMap(pr => closesOf(pr.body ?? '').map(n => [n, pr.number as number])))
35  const raws = open.filter(raw => !raw.pull_request)
36  const issues: Issue[] = raws.map(raw => ({
37    number: raw.number,
38    title: raw.title,
39    url: raw.html_url,
40    labels: raw.labels.map((l: Raw) => l.name),
41    assignees: raw.assignees.map((a: Raw) => a.login),
42    children: [],
43    subs: { total: raw.sub_issues_summary?.total ?? 0, done: raw.sub_issues_summary?.completed ?? 0 },
44    blockers: [],
45    pr: closer.get(raw.number),
46    worktree: worktrees.get(raw.number),
47  }))
48  // Sub-issues and blockers may live in another repo, so they match by url, never by number.
49  const byUrl = new Map(issues.map(issue => [issue.url, issue]))
50
51  await inTurn(
52    issues.flatMap((issue, i) => [
53      issue.subs.total > 0 && (() =>
54        list(run, `repos/${repo}/issues/${issue.number}/sub_issues?per_page=100`).then(subs => {
55          const children = subs.flatMap(s => byUrl.get(s.html_url) ?? [])
56          issue.children = children.map(child => child.number)
57          for (const child of children) child.parent = issue.number
58        })),
59      raws[i]!.issue_dependencies_summary?.blocked_by > 0 && (() =>
60        list(run, `repos/${repo}/issues/${issue.number}/dependencies/blocked_by`).then(blockers => {
61          issue.blockers = blockers
62            .filter(b => b.state === 'open')
63            .map(b => {
64              const where = b.repository_url.slice(b.repository_url.lastIndexOf('/repos/') + '/repos/'.length)
65              return { label: where === repo ? `#${b.number}` : `${where}#${b.number}`, url: b.html_url }
66            })
67        })),
68      issue.assignees.length > 0 && (() =>
69        list(run, `repos/${repo}/issues/${issue.number}/events?per_page=100`).then(events => {
70          issue.claimedAt = events.findLast(ev => ev.event === 'assigned')?.created_at
71        })),
72    ]).filter(task => task !== false),
73  )
74  return { repo, issues, prs: prs.length, fetchedAt: now }
75}
76
77/** Whether PR `n` of the session repo is merged. */
78export async function isMerged(run: Runner, n: number): Promise<boolean> {
79  return (await run(['gh', 'api', `repos/{owner}/{repo}/pulls/${n}`, '--jq', '.merged'])).trim() === 'true'
80}
81
hooks/rules.ts 67 lines
1// The pane's rules as pure functions, so a test reaches them without the pane.
2
3const STALE_HOURS = 24
4
5export type RunLine = { text: string; isStale: boolean }
6
7/**
8 * The line under a claimed issue, first match wins: a claim older than a day with no PR closing it is stale, unless
9 * the issue closes without a PR (`expectsPr: false`); a matching ship worktree names the run; with neither and no PR,
10 * the claim's age. A claim with a PR and no worktree has no line, since its row already says `in PR #N`.
11 */
12export function runLine(run: { claimedAt?: string; now: string; hasPr: boolean; expectsPr?: boolean; worktree?: string }): RunLine | null {
13  const hours = run.claimedAt === undefined ? undefined : (Date.parse(run.now) - Date.parse(run.claimedAt)) / 3_600_000
14  const age = hours === undefined ? '' : hours < 24 ? ` ${Math.floor(hours)}h ago` : ` ${Math.floor(hours / 24)}d ago`
15  const expectsPr = run.expectsPr ?? true
16  if (expectsPr && !run.hasPr && hours !== undefined && hours > STALE_HOURS) {
17    return { text: `✗ stale claim: claimed${age}, no PR`, isStale: true }
18  }
19  if (run.worktree !== undefined) return { text: `◐ ship worktree ${run.worktree}`, isStale: false }
20  if (run.hasPr) return null
21  return { text: `◐ claimed${age}${expectsPr ? ', no PR yet' : ''}`, isStale: false }
22}
23
24const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25
26/**
27 * The issue a worktree belongs to under `layout`, a path relative to the main checkout's parent folder in which
28 * `{repo}` is the main checkout's folder name, `{slug}` any one path segment and `{n}` the issue number.
29 */
30export function worktreeIssue(layout: string, main: string, path: string): number | undefined {
31  const parent = main.slice(0, main.lastIndexOf('/') + 1)
32  if (!path.startsWith(parent)) return undefined
33  const repo = main.slice(parent.length)
34  const pattern = layout
35    .split(/(\{repo\}|\{slug\}|\{n\})/)
36    .map(part => (part === '{repo}' ? escape(repo) : part === '{slug}' ? '[^/]+' : part === '{n}' ? '(\\d+)' : escape(part)))
37    .join('')
38  const n = new RegExp(`^${pattern}$`).exec(path.slice(parent.length))?.[1]
39  return n === undefined ? undefined : Number(n)
40}
41
42/** The issues a PR body closes, by GitHub's closing keywords on a same-repo `#N`. */
43export const closesOf = (body: string): number[] =>
44  [...body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi)].map(m => Number(m[1]))
45
46/** A template with each `{key}` replaced by its value. */
47export const fill = (template: string, values: Record<string, string | number>): string =>
48  template.replace(/\{(\w+)\}/g, (whole, key: string) => (Object.hasOwn(values, key) ? String(values[key]) : whole))
49
50/** The PR a merge-gate answer names: the number in its first `/pull/<N>`, when the answer carries the gate text. */
51export function gatePr(answer: string, gateText: string): number | undefined {
52  const n = answer.includes(gateText) ? /\/pull\/(\d+)/.exec(answer)?.[1] : undefined
53  return n === undefined ? undefined : Number(n)
54}
55
56/** The PR a merged answer names: the first `/pull/<N>` after the merged text on that text's line, as ship's merged line ends. */
57export function mergedPrOf(answer: string, mergedText: string): number | undefined {
58  const at = answer.indexOf(mergedText)
59  return at === -1 ? undefined : gatePr(answer.slice(at).split('\n')[0]!, mergedText)
60}
61
62/** A slash command's name and args, or undefined for text that is no command. */
63export function commandOf(text: string): { command: string; args: string } | undefined {
64  const match = /^\/(\S+)(?:\s+(.*))?$/s.exec(text.trim())
65  return match === null ? undefined : { command: match[1]!, args: match[2] ?? '' }
66}
67
types/index.d.ts 43 lines
1// The state contract of the gh-pane mod.
2
3/** One open issue of the session repo, as the pane orders it. */
4export type Issue = {
5  number: number
6  title: string
7  url: string
8  labels: string[]
9  assignees: string[]
10  /** Its open sub-issues in this repo, in the parent's order. */
11  children: number[]
12  /** Its sub-issues, all of them and the closed ones (GitHub's `sub_issues_summary`). */
13  subs: { total: number; done: number }
14  /** The open issue it is a sub-issue of. */
15  parent?: number
16  /** Its open blockers, labelled `#N` in this repo and `owner/repo#N` in another. */
17  blockers: { label: string; url: string }[]
18  /** The open PR whose body closes it. */
19  pr?: number
20  /** When it was last assigned: the claim's start. */
21  claimedAt?: string
22  /** The folder name of its ship worktree, when one matches the layout. */
23  worktree?: string
24}
25
26export type Snapshot =
27  | { repo: string; issues: Issue[]; prs: number; fetchedAt: string }
28  | { error: string }
29
30/** Where this session stands at a ship run's merge gate, or once a ship run merged on its own: the PR and the band's offer. */
31export type Gate = {
32  pr: number
33  phase: 'gate' | 'merging' | 'merged'
34  /** Once merged: the command the next button runs after a /clear, the first ready row's. None when nothing is ready or its command is no slash command. */
35  next?: { text: string; command: string; args: string }
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'gh-pane': { snapshot: Snapshot | null; gate: Gate | null }
41  }
42}
43