Lists the PRs created in this session (gh pr create, gh stack submit) with their CI status above the prompt; a button hides or shows the list, and each open PR…

Mods for Claude Code: hooks-module plugins that add live panels and controls to the terminal UI.
Shows the PRs created in the current session, with their CI status, in a band above the prompt and in a side panel.
▌ PRs [n] │ ✓ passed ✗ failed ◌ running │ [ Panel ○ ]. The button toggles the panel on and off.+added −deleted, branch → base, the names of the failed checks (passed, running and skipped checks are not listed), review decision, merge state, a link to the PR, and the time since the last fetch. A proportional bar at the top sums up all checks.gh pr create the session runs through the Bash tool, including those run by subagents. From gh stack submit only the Created PR #N (URL) lines are taken: PRs already in the stack (is up to date) and the stack's own number are not added.gh pr view --json and refreshes every 60 seconds; [ Refresh ] fetches at once./prs also toggles the panel./tui relaunch.[ Squash & merge ] button. It asks "Squash and merge #N?" first; Yes runs gh pr merge <url> --squash, after gh pr ready when the PR is still a draft (the question then says "marks it ready first"). Success toasts and refreshes the list; failure shows the first line of gh's error on the card and brings the button back.Shows the open PRs where you are a requested reviewer, with their CI status, in a band and a side panel.
gh search prs --review-requested=@me --state=open, then adds CI, diff size and review state from gh pr view.▌ Reviews [n] │ ✗ k with failing CI │ [ Panel ○ ], or CI green on all when nothing fails.repo #number, a DRAFT chip, +added −deleted, title, @author, review state, time since the last update, the check counts with the names of failing checks, and a link. Newest update first.[ Refresh ] fetches at once. /reviews toggles the panel.With both mods installed the band has two rows: each mod draws its own row and stacks the row of the mod beneath it.
/tui fullscreen (/tui default switches back). On the classic renderer use /prs and /reviews, or focus the band with ctrl+x tab: p toggles the PR panel, v the review panel; inside a panel r refreshes and q closes it; in the PR panel m is the merge button of the first open PR, and y / n answer its question.gh must be installed and signed in.REFRESH_MS constant at the top of its hooks/register.tsx.At the prompt of a terminal session:
/plugin install session-prs --marketplace sinozu/claude-code-mods
/plugin install review-requests --marketplace sinozu/claude-code-mods
From a shell:
claude plugin install session-prs --marketplace sinozu/claude-code-mods
claude plugin install review-requests --marketplace sinozu/claude-code-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with sinozu/claude-code-mods, then Add plugin and pick the mod.
claude --plugin-dir ./mods/session-prs --plugin-dir ./mods/review-requests
claude plugin validate ./mods/<mod>
claude plugin test ./mods/<mod>hooks/register.tsx 479 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Bucket, CheckRun, Checks, Merge, Pr } from '../types'
5
6const PANE = 'session-prs'
7const REFRESH_MS = 60_000
8const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g
9const CREATES_PR = /\bgh\s+pr\s+create\b/
10const SUBMITS_STACK = /\bgh\s+stack\s+submit\b/
11// `gh stack submit` names every PR of the stack; only the "Created PR" lines are new ones.
12const STACK_CREATED = /\bCreated PR #\d+ \((https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)\)/g
13const FIELDS = 'number,title,state,isDraft,headRefName,baseRefName,additions,deletions,reviewDecision,mergeStateStatus,statusCheckRollup'
14
15const prs = atom({ plugin: 'session-prs', key: 'prs' } as const, [])
16const isOpen = atom({ plugin: 'session-prs', key: 'isOpen' } as const, false)
17const merge = atom({ plugin: 'session-prs', key: 'merge' } as const, null as Merge | null)
18
19type Rollup =
20 | { __typename: 'CheckRun'; name: string; status: string; conclusion: string | null }
21 | { __typename: 'StatusContext'; context: string; state: string }
22
23type View = {
24 number: number
25 title: string
26 state: string
27 isDraft: boolean
28 headRefName: string
29 baseRefName: string
30 additions: number
31 deletions: number
32 reviewDecision: string
33 mergeStateStatus: string
34 statusCheckRollup: Rollup[]
35}
36
37type Color = 'success' | 'error' | 'warning' | 'subtle' | 'merged' | 'text'
38
39const BUCKET: Record<Bucket, { glyph: string; color: Color; label: string }> = {
40 pass: { glyph: '✓', color: 'success', label: 'passed' },
41 fail: { glyph: '✗', color: 'error', label: 'failed' },
42 pending: { glyph: '◌', color: 'warning', label: 'running' },
43 skipped: { glyph: '↷', color: 'subtle', label: 'skipped' },
44}
45
46const bucketOf = (item: Rollup): Bucket => {
47 if (item.__typename === 'StatusContext') {
48 if (item.state === 'SUCCESS') return 'pass'
49 if (item.state === 'PENDING' || item.state === 'EXPECTED') return 'pending'
50 return 'fail'
51 }
52 if (item.status !== 'COMPLETED') return 'pending'
53 if (item.conclusion === 'SUCCESS' || item.conclusion === 'NEUTRAL') return 'pass'
54 if (item.conclusion === 'SKIPPED') return 'skipped'
55 return 'fail'
56}
57
58const nameOf = (item: Rollup) => (item.__typename === 'StatusContext' ? item.context : item.name)
59
60const zero = (): Checks => ({ pass: 0, fail: 0, pending: 0, skipped: 0 })
61
62const emptyPr = (url: string): Pr => ({
63 url,
64 number: Number(url.split('/').pop()),
65 title: '',
66 state: 'UNKNOWN',
67 isDraft: false,
68 headRefName: '',
69 baseRefName: '',
70 additions: 0,
71 deletions: 0,
72 reviewDecision: '',
73 mergeStateStatus: '',
74 checks: zero(),
75 runs: [],
76 updatedAt: 0,
77 error: null,
78})
79
80const fetchPr = async ($: EngineInterface, pr: Pr): Promise<Pr> => {
81 const updatedAt = await $.clock.now()
82 try {
83 const ran = await $.process.run(['gh', 'pr', 'view', pr.url, '--json', FIELDS], { timeoutMs: 20_000 })
84 if (ran.exitCode !== 0) {
85 return { ...pr, updatedAt, error: ran.stderr.trim().split('\n')[0] || `gh exited ${ran.exitCode}` }
86 }
87 const view = JSON.parse(ran.stdout) as View
88 const checks = zero()
89 const runs: CheckRun[] = []
90 for (const item of view.statusCheckRollup ?? []) {
91 const bucket = bucketOf(item)
92 checks[bucket] += 1
93 runs.push({ name: nameOf(item), bucket })
94 }
95 const state = view.state === 'OPEN' || view.state === 'MERGED' || view.state === 'CLOSED' ? view.state : 'UNKNOWN'
96 return {
97 ...pr,
98 number: view.number,
99 title: view.title,
100 state,
101 isDraft: view.isDraft,
102 headRefName: view.headRefName ?? '',
103 baseRefName: view.baseRefName ?? '',
104 additions: view.additions ?? 0,
105 deletions: view.deletions ?? 0,
106 reviewDecision: view.reviewDecision ?? '',
107 mergeStateStatus: view.mergeStateStatus ?? '',
108 checks,
109 runs,
110 updatedAt,
111 error: null,
112 }
113 } catch (error) {
114 return { ...pr, updatedAt, error: error instanceof Error ? error.message : String(error) }
115 }
116}
117
118const refreshAll = async ($: EngineInterface) => {
119 const list = await read($, prs)
120 if (list.length === 0) return
121 const fresh = await Promise.all(list.map(pr => fetchPr($, pr)))
122 await update($, prs, current => current.map(pr => fresh.find(one => one.url === pr.url) ?? pr))
123 await persist($)
124}
125
126const createdIn = (command: string, text: string): string[] => {
127 const urls = new Set<string>()
128 if (CREATES_PR.test(command)) for (const url of text.match(PR_URL) ?? []) urls.add(url)
129 if (SUBMITS_STACK.test(command)) for (const [, url] of text.matchAll(STACK_CREATED)) if (url !== undefined) urls.add(url)
130 return [...urls]
131}
132
133const addPrs = async ($: EngineInterface, urls: string[]) => {
134 const added: Pr[] = []
135 await update($, prs, current => {
136 const known = new Set(current.map(pr => pr.url))
137 for (const url of urls) {
138 if (!known.has(url)) {
139 known.add(url)
140 added.push(emptyPr(url))
141 }
142 }
143 return [...current, ...added]
144 })
145 if (added.length > 0) await refreshAll($)
146}
147
148const askMerge = async ($: EngineInterface, url: string) => {
149 await update($, merge, () => ({ url, phase: 'confirm', message: '' }))
150}
151
152const cancelMerge = async ($: EngineInterface) => {
153 await update($, merge, () => null)
154}
155
156const gh = async ($: EngineInterface, argv: string[]): Promise<string | null> => {
157 try {
158 const ran = await $.process.run(['gh', ...argv], { timeoutMs: 60_000 })
159 if (ran.exitCode === 0) return null
160 return ran.stderr.trim().split('\n')[0] || `gh exited ${ran.exitCode}`
161 } catch (error) {
162 return error instanceof Error ? error.message : String(error)
163 }
164}
165
166// A draft cannot be merged, so it is marked ready first; then squash-merged.
167const runMerge = async ($: EngineInterface) => {
168 const asked = await read($, merge)
169 if (asked === null || asked.phase !== 'confirm') return
170 const pr = (await read($, prs)).find(one => one.url === asked.url)
171 if (pr === undefined) return cancelMerge($)
172 await update($, merge, () => ({ ...asked, phase: 'running' }))
173 const failed = (pr.isDraft ? await gh($, ['pr', 'ready', pr.url]) : null) ?? (await gh($, ['pr', 'merge', pr.url, '--squash']))
174 if (failed !== null) {
175 await update($, merge, () => ({ ...asked, phase: 'failed', message: failed }))
176 return
177 }
178 await update($, merge, () => null)
179 void $.ui.toast(`#${pr.number} squash-merged`)
180 await refreshAll($)
181}
182
183const openPane = async ($: EngineInterface) => {
184 await update($, isOpen, () => true)
185 await $.ui.open({ id: PANE, title: 'PRs' })
186 await persist($)
187}
188
189const closePane = async ($: EngineInterface) => {
190 await update($, isOpen, () => false)
191 await $.ui.close({ id: PANE })
192 await persist($)
193}
194
195// The session state is lost when the process relaunches (`/tui`), so the
196// list and the panel state are also kept in the store under the session id.
197const storeKey = async ($: EngineInterface, name: 'prs' | 'open') => `${name}:${await $.session.id()}`
198
199const persist = async ($: EngineInterface) => {
200 await $.store.set(await storeKey($, 'prs'), await read($, prs))
201 await $.store.set(await storeKey($, 'open'), await read($, isOpen))
202}
203
204const restore = async ($: EngineInterface) => {
205 const saved = await $.store.get(await storeKey($, 'prs'))
206 if (Array.isArray(saved) && saved.length > 0 && (await read($, prs)).length === 0) {
207 await update($, prs, () => saved as Pr[])
208 }
209 const open = await $.store.get(await storeKey($, 'open'))
210 if (open === true) await update($, isOpen, () => true)
211}
212
213const totalOf = (list: Pr[]): Checks =>
214 list.reduce((sum, pr) => {
215 for (const bucket of Object.keys(sum) as Bucket[]) sum[bucket] += pr.checks[bucket]
216 return sum
217 }, zero())
218
219const toneOf = (pr: Pr): { glyph: string; color: Color } => {
220 if (pr.error !== null) return { glyph: '!', color: 'warning' }
221 if (pr.checks.fail > 0) return BUCKET.fail
222 if (pr.checks.pending > 0) return BUCKET.pending
223 if (pr.checks.pass > 0) return BUCKET.pass
224 return { glyph: '·', color: 'subtle' }
225}
226
227const stateColor = (state: Pr['state']): Color =>
228 state === 'MERGED' ? 'merged' : state === 'CLOSED' ? 'error' : state === 'OPEN' ? 'success' : 'subtle'
229
230const reviewOf = (decision: string): { label: string; color: Color } => {
231 if (decision === 'APPROVED') return { label: 'approved', color: 'success' }
232 if (decision === 'CHANGES_REQUESTED') return { label: 'changes requested', color: 'error' }
233 if (decision === 'REVIEW_REQUIRED') return { label: 'review required', color: 'warning' }
234 return { label: 'no review', color: 'subtle' }
235}
236
237const mergeOf = (status: string): { label: string; color: Color } => {
238 if (status === 'CLEAN') return { label: 'mergeable', color: 'success' }
239 if (status === 'BLOCKED') return { label: 'blocked', color: 'warning' }
240 if (status === 'DIRTY') return { label: 'conflicts', color: 'error' }
241 if (status === 'UNSTABLE') return { label: 'checks failing', color: 'error' }
242 if (status === 'BEHIND') return { label: 'behind base', color: 'warning' }
243 if (status === 'DRAFT') return { label: 'draft', color: 'subtle' }
244 return { label: status.toLowerCase() || 'unknown', color: 'subtle' }
245}
246
247const ageOf = (now: number, then: number) => {
248 if (then === 0) return 'fetching'
249 const seconds = Math.max(0, Math.round((now - then) / 1000))
250 if (seconds < 60) return `${seconds}s ago`
251 const minutes = Math.round(seconds / 60)
252 return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`
253}
254
255// Splits `cells` cells across the buckets in proportion to their counts.
256const barOf = (checks: Checks, cells: number): { bucket: Bucket; cells: number }[] => {
257 const total = Object.values(checks).reduce((sum, count) => sum + count, 0)
258 if (total === 0) return []
259 const order: Bucket[] = ['pass', 'fail', 'pending', 'skipped']
260 let used = 0
261 return order
262 .map((bucket, index) => {
263 const share = index === order.length - 1 ? cells - used : Math.round((checks[bucket] / total) * cells)
264 const width = checks[bucket] === 0 ? 0 : Math.max(1, Math.min(share, cells - used))
265 used += width
266 return { bucket, cells: width }
267 })
268 .filter(segment => segment.cells > 0)
269}
270
271export const register: Register = on => {
272 on('session.start', async ($, e, next) => {
273 await $.command.register({
274 name: 'prs',
275 description: 'Open or close the panel of PRs created in this session',
276 })
277 $.clock.every(REFRESH_MS, () => void refreshAll($))
278 await restore($)
279 if (await read($, isOpen)) void $.ui.open({ id: PANE, title: 'PRs' })
280
281 return next(e)
282 })
283
284 on('command.run', { command: 'prs' }, async $ => {
285 const open = await read($, isOpen)
286 if (open) {
287 await closePane($)
288 return { text: 'PR panel closed.' }
289 }
290 await openPane($)
291 void refreshAll($)
292 const list = await read($, prs)
293
294 return { text: list.length === 0 ? 'PR panel opened. No PR has been created in this session yet.' : `PR panel opened. ${list.length} PR(s).` }
295 })
296
297 on('ui.close', { id: PANE }, async ($, e, next) => {
298 if (e.origin.kind === 'person') {
299 await update($, isOpen, () => false)
300 await persist($)
301 }
302
303 return next(e)
304 })
305
306 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
307 const ran = await next(e)
308 if (ran.deny !== undefined) return ran
309 const urls = createdIn(e.command, ran.text ?? '')
310 if (urls.length > 0) void addPrs($, urls)
311
312 return ran
313 }).catch(($, e, next) => next(e))
314
315 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316 if (e.props.hasSurvey) return next(e)
317
318 const { Box, Button, Text } = $.ui.resolve(e)
319 const list = await read($, prs)
320 const open = await read($, isOpen)
321 const total = totalOf(list)
322 const worst = list.find(pr => pr.checks.fail > 0) ?? list.find(pr => pr.checks.pending > 0) ?? list[0]
323 const tone = worst ? toneOf(worst) : { glyph: '·', color: 'subtle' as Color }
324 const below = await next(e)
325 const rest = below.type === 'engine' ? null : below
326
327 return (
328 <Box flexDirection="column">
329 <Box gap={1}>
330 <Text color={tone.color} bold>▌</Text>
331 <Text bold>PRs</Text>
332 <Text inverse color={tone.color}> {list.length} </Text>
333 <Text dimColor>│</Text>
334 <Text color="success">✓ {total.pass}</Text>
335 <Text color={total.fail > 0 ? 'error' : 'subtle'}>✗ {total.fail}</Text>
336 <Text color={total.pending > 0 ? 'warning' : 'subtle'}>◌ {total.pending}</Text>
337 <Text dimColor>│</Text>
338 <Button key="toggle" hotkey="p" label={open ? 'Panel ●' : 'Panel ○'} onPress={() => (open ? closePane($) : openPane($))} />
339 {e.viewport?.isFullscreen === false && <Text dimColor>/tui fullscreen enables clicks, or /prs</Text>}
340 </Box>
341 {rest}
342 </Box>
343 )
344 })
345
346 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347 const { Box, Button, Link, Text } = $.ui.resolve(e)
348 const list = await read($, prs)
349 const asked = await read($, merge)
350 const now = await $.clock.now()
351 const total = totalOf(list)
352 const width = Math.max(30, e.props.bodyColumns)
353 const firstOpen = list.find(pr => pr.state === 'OPEN')
354 const inner = width - 4
355 const byState = { open: 0, merged: 0, closed: 0 }
356 for (const pr of list) {
357 if (pr.state === 'OPEN') byState.open += 1
358 else if (pr.state === 'MERGED') byState.merged += 1
359 else if (pr.state === 'CLOSED') byState.closed += 1
360 }
361
362 return (
363 <Box flexDirection="column" width={width}>
364 <Box justifyContent="space-between" paddingX={1}>
365 <Box gap={1}>
366 <Text color="claude" bold>▌</Text>
367 <Text bold>PRs in this session</Text>
368 </Box>
369 <Box gap={1}>
370 <Button key="refresh" hotkey="r" label="Refresh" onPress={() => refreshAll($)} />
371 <Button key="close" hotkey="q" label="Close" onPress={() => closePane($)} />
372 </Box>
373 </Box>
374 <Box paddingX={1} gap={1}>
375 <Text color="success">{byState.open} open</Text>
376 <Text dimColor>·</Text>
377 <Text color="merged">{byState.merged} merged</Text>
378 <Text dimColor>·</Text>
379 <Text color={byState.closed > 0 ? 'error' : 'subtle'}>{byState.closed} closed</Text>
380 <Text dimColor>·</Text>
381 <Text dimColor>auto-refresh every {REFRESH_MS / 1000}s, last {ageOf(now, Math.max(0, ...list.map(pr => pr.updatedAt)))}</Text>
382 </Box>
383 {list.length > 0 && (
384 <Box flexDirection="column" paddingX={1} marginBottom={1}>
385 <Box>
386 {barOf(total, inner).map(segment => (
387 <Text key={segment.bucket} color={BUCKET[segment.bucket].color}>{'█'.repeat(segment.cells)}</Text>
388 ))}
389 {barOf(total, inner).length === 0 && <Text dimColor>{'░'.repeat(inner)}</Text>}
390 </Box>
391 <Box gap={2}>
392 {(Object.keys(BUCKET) as Bucket[]).map(bucket => (
393 <Text key={bucket} color={total[bucket] > 0 ? BUCKET[bucket].color : 'subtle'}>
394 {BUCKET[bucket].glyph} {total[bucket]} {BUCKET[bucket].label}
395 </Text>
396 ))}
397 </Box>
398 </Box>
399 )}
400 {list.length === 0 && (
401 <Box paddingX={1} marginTop={1}>
402 <Text dimColor>No PR yet. The next `gh pr create` or `gh stack submit` in this session shows up here.</Text>
403 </Box>
404 )}
405 {list.map(pr => {
406 const tone = toneOf(pr)
407 const review = reviewOf(pr.reviewDecision)
408 const mergeable = mergeOf(pr.mergeStateStatus)
409 const mine = asked?.url === pr.url ? asked : null
410 const failed = pr.runs.filter(run => run.bucket === 'fail')
411 const shown = failed.slice(0, 8)
412 return (
413 <Box key={pr.url} flexDirection="column" borderStyle="round" borderColor={tone.color} paddingX={1} width={width}>
414 <Box justifyContent="space-between">
415 <Box gap={1}>
416 <Text color={tone.color} bold>{tone.glyph}</Text>
417 <Text bold>#{pr.number}</Text>
418 {pr.isDraft && pr.state === 'OPEN' ? (
419 <Text inverse color="subtle"> DRAFT </Text>
420 ) : (
421 <Text inverse color={stateColor(pr.state)}> {pr.state} </Text>
422 )}
423 </Box>
424 <Box gap={1}>
425 <Text color="diffAdded">+{pr.additions}</Text>
426 <Text color="diffRemoved">−{pr.deletions}</Text>
427 </Box>
428 </Box>
429 <Text bold wrap="truncate">{pr.title || pr.url}</Text>
430 {pr.headRefName !== '' && (
431 <Box gap={1}>
432 <Text dimColor>⎇</Text>
433 <Text color="suggestion" wrap="truncate">{pr.headRefName}</Text>
434 <Text dimColor>→ {pr.baseRefName}</Text>
435 </Box>
436 )}
437 <Text dimColor>{'─'.repeat(inner)}</Text>
438 {shown.length > 0 && (
439 <Box flexWrap="wrap" columnGap={2}>
440 {shown.map(run => (
441 <Text key={run.name} color={BUCKET[run.bucket].color} wrap="truncate">
442 {BUCKET[run.bucket].glyph} {run.name}
443 </Text>
444 ))}
445 {failed.length > shown.length && <Text dimColor>+{failed.length - shown.length} more</Text>}
446 </Box>
447 )}
448 {pr.error !== null && <Text color="warning" wrap="truncate">gh: {pr.error}</Text>}
449 <Box justifyContent="space-between">
450 <Box gap={1}>
451 <Text color={review.color}>{review.label}</Text>
452 <Text dimColor>·</Text>
453 <Text color={mergeable.color}>{mergeable.label}</Text>
454 </Box>
455 <Text dimColor>{ageOf(now, pr.updatedAt)}</Text>
456 </Box>
457 {pr.state === 'OPEN' && mine?.phase === 'confirm' && (
458 <Box gap={1}>
459 <Text color="warning">Squash and merge #{pr.number}{pr.isDraft ? ' (marks it ready first)' : ''}?</Text>
460 <Button key={`merge-yes:${pr.url}`} hotkey="y" variant="primary" label="Yes" onPress={() => runMerge($)} />
461 <Button key={`merge-no:${pr.url}`} hotkey="n" label="No" onPress={() => cancelMerge($)} />
462 </Box>
463 )}
464 {pr.state === 'OPEN' && mine?.phase === 'running' && <Text color="warning">merging…</Text>}
465 {pr.state === 'OPEN' && mine?.phase !== 'confirm' && mine?.phase !== 'running' && (
466 <Box gap={1}>
467 <Button key={`merge:${pr.url}`} hotkey={pr === firstOpen ? 'm' : undefined} label="Squash & merge" onPress={() => askMerge($, pr.url)} />
468 {mine?.phase === 'failed' && <Text color="error" wrap="truncate">merge failed: {mine.message}</Text>}
469 </Box>
470 )}
471 <Link href={pr.url} label={pr.url.replace('https://github.com/', '')} />
472 </Box>
473 )
474 })}
475 </Box>
476 )
477 })
478}
479types/index.d.ts 35 lines1export type Bucket = 'pass' | 'fail' | 'pending' | 'skipped'
2
3export type Checks = Record<Bucket, number>
4
5export type CheckRun = { name: string; bucket: Bucket }
6
7export type Pr = {
8 url: string
9 number: number
10 title: string
11 state: 'OPEN' | 'MERGED' | 'CLOSED' | 'UNKNOWN'
12 isDraft: boolean
13 headRefName: string
14 baseRefName: string
15 additions: number
16 deletions: number
17 reviewDecision: string
18 mergeStateStatus: string
19 checks: Checks
20 runs: CheckRun[]
21 updatedAt: number
22 error: string | null
23}
24
25export type MergePhase = 'confirm' | 'running' | 'failed'
26
27// The one squash merge in flight: asked, running, or failed with gh's first stderr line.
28export type Merge = { url: string; phase: MergePhase; message: string }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'session-prs': { prs: Pr[]; isOpen: boolean; merge: Merge | null }
33 }
34}
35