List the pull requests created in this session in a band above the prompt, and every recorded PR with the session that created it.

Personal Claude Code plugins.
/plugin marketplace add itkrt2y/claude-plugins
Compacts the main conversation once after 40 idle minutes, while the 1h prompt cache is still warm, so the next turn after a long break starts from a small context instead of re-caching the whole one.
Function hooks are early access: set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, for example in the env block of ~/.claude/settings.json.
/plugin install idle-compact@itkrt2y-claude-plugins
Inspired by takahirom's idle-compact.
Respond to pull request review threads with the respond-reviews skill. One script, scripts/review-threads.sh, lists unresolved threads as JSON, replies inside a thread, resolves threads and edits posted replies, so Claude does not hand-write GraphQL queries each time. Requires gh and jq.
/plugin install review-threads@itkrt2y-claude-plugins
The rebase skill tries git rebase in the main conversation and hands the rebase to a subagent only when it stops on conflicts, so conflict diffs and git output stay out of the main context. The subagent never pushes.
/plugin install rebase@itkrt2y-claude-plugins
The circleci-triage skill fetches failed CircleCI tests with the circleci CLI in a subagent, then tells flaky failures from regressions by reading the actual values and reproducing locally.
/plugin install circleci-triage@itkrt2y-claude-plugins
~/.claude/projects/<project>/RULES.md as always-on rules for that project. Claude Code's auto memory loads only the first 200 lines or 25KB of memory/MEMORY.md; turn on the inject_memory_index option to inject the whole index as well.memory-index-compact skill shortens MEMORY.md when it nears the limit while keeping every link and every status of unfinished work./plugin install project-memory@itkrt2y-claude-plugins
Shows Claude Code notifications on the GNOME desktop, titled with the session name set by /rename, else the git branch, else the directory name, so you can tell parallel sessions apart.
Supported environment: a local GNOME session on Linux. Tested with GNOME Shell 50 (Wayland) on Fedora 44. Other desktops, macOS, WSL, and SSH or headless sessions without a notification server are not supported.
Requirements (Fedora package names in parentheses):
notify-send (libnotify)jq (jq)tac and timeout from GNU coreutils (coreutils)canberra-gtk-play (libcanberra-gtk3) and the freedesktop sound theme (sound-theme-freedesktop) to play a sound. Without them, notifications are silent./plugin install desktop-notify@itkrt2y-claude-plugins
Lists the pull requests created in the current session in a band above the prompt, each linked as owner/repo#number.
gh pr create succeeds in the Bash tool, or when an MCP tool named create_pull_request (such as the GitHub MCP server's) succeeds. The PR URL is read from the tool's output, so GitHub Enterprise hosts work too./session-prs or the Hide button hides the band; the next PR created shows it again.Every PR it picks up also goes into an index kept in the plugin's store and shared by all sessions, with the session ID, working directory and branch of the session that recorded it first. /session-prs list lists that index from any session, wherever each session's worktree lives:
gh and the claude --resume <session-id> command for the session that created it.claude --from-pr <number>./session-prs prune drops them from the index.The states come from gh pr list --author @me, so /session-prs list needs gh signed in to each host. A session created before the plugin was installed joins the index when it is resumed. Claude Code deletes transcripts after cleanupPeriodDays (30 days by default), so raise it if PRs stay open longer than that.
Function hooks are early access: set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, for example in the env block of ~/.claude/settings.json.
/plugin install session-prs@itkrt2y-claude-plugins
claude plugin validate .
for p in plugins/*/; do claude plugin validate "$p"; done
for p in plugins/idle-compact plugins/session-prs; do CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test "$p"; donehooks/session-prs.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CreatedPr, IndexedPr, SessionPrs } from '../types'
5
6const band = atom({ plugin: 'session-prs', key: 'band' } as const, {
7 prs: [],
8 isHidden: false,
9} as SessionPrs)
10
11const GH_PR_CREATE = /\bgh\s+pr\s+create\b/
12// Any MCP server's PR creation tool, e.g. the GitHub MCP server's create_pull_request.
13const MCP_PR_CREATE = /^mcp__.+__create_pull_request$/
14const PR_URL = /https:\/\/[\w.-]+(?::\d+)?\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)\b/
15const PR_URL_EXACT = new RegExp(`^${PR_URL.source}$`)
16
17const fromMatch = (m: RegExpExecArray | null): CreatedPr | undefined =>
18 m ? { url: m[0], repo: m[1] ?? '', number: Number(m[2]) } : undefined
19
20// gh pr create prints each created PR's URL on a line of its own, so a chained
21// `gh pr view` (`url:\t<URL>`) or `gh pr list` does not add PRs it only shows.
22export const parseGhOutput = (stdout: string): CreatedPr[] =>
23 stdout.split('\n').flatMap(line => fromMatch(PR_URL_EXACT.exec(line.trim())) ?? [])
24
25// The result echoes the PR body, which may link other PRs, so a JSON result's URL
26// field wins, and plain text yields only its first PR URL.
27export const parseMcpResult = (text: string): CreatedPr[] => {
28 try {
29 const json: unknown = JSON.parse(text)
30 if (typeof json === 'object' && json !== null) {
31 for (const key of ['html_url', 'url']) {
32 const value = (json as Record<string, unknown>)[key]
33 const pr = typeof value === 'string' ? fromMatch(PR_URL_EXACT.exec(value)) : undefined
34 if (pr) {
35 return [pr]
36 }
37 }
38 }
39 } catch {}
40
41 const pr = fromMatch(PR_URL.exec(text))
42 return pr ? [pr] : []
43}
44
45// The PRs one successful tool call created, from its input and its result.
46export const prsFromToolUse = (
47 tool: string,
48 input: Record<string, unknown>,
49 result: unknown,
50 text: string | undefined,
51): CreatedPr[] => {
52 if (tool === 'Bash') {
53 const stdout = (result as { stdout?: unknown } | undefined)?.stdout
54 return typeof input.command === 'string' && GH_PR_CREATE.test(input.command) && typeof stdout === 'string'
55 ? parseGhOutput(stdout)
56 : []
57 }
58 return MCP_PR_CREATE.test(tool) && text !== undefined ? parseMcpResult(text) : []
59}
60
61const addNew = <T extends CreatedPr>(known: readonly T[], found: readonly CreatedPr[], toEntry: (pr: CreatedPr) => T) =>
62 found.reduce((acc, pr) => (acc.some(k => k.url === pr.url) ? acc : [...acc, toEntry(pr)]), known as T[])
63
64// The index of every recorded PR lives in the plugin's store, which every session shares.
65const INDEX_KEY = 'index'
66
67const readIndex = async ($: EngineInterface): Promise<IndexedPr[]> => {
68 const value = await $.store.get(INDEX_KEY)
69 return Array.isArray(value) ? (value as IndexedPr[]) : []
70}
71
72const currentBranch = async ($: EngineInterface, cwd: string) => {
73 try {
74 const ran = await $.process.run(['git', 'branch', '--show-current'], { cwd })
75 return ran.exitCode === 0 ? ran.stdout.trim() || null : null
76 } catch {
77 return null
78 }
79}
80
81// A PR keeps the session that recorded it first.
82const addToIndex = async ($: EngineInterface, found: CreatedPr[]) => {
83 const index = await readIndex($)
84 if (found.every(pr => index.some(known => known.url === pr.url))) {
85 return
86 }
87 const sessionId = await $.session.id()
88 const cwd = await $.session.cwd()
89 const branch = await currentBranch($, cwd)
90 const recordedAt = new Date().toISOString()
91 await $.store.set(INDEX_KEY, addNew(index, found, pr => ({ ...pr, sessionId, cwd, branch, recordedAt })))
92}
93
94// A PR created now shows the band again; PRs found in the transcript keep it as it is.
95const record = async ($: EngineInterface, found: CreatedPr[], reveal: boolean) => {
96 if (found.length === 0) {
97 return
98 }
99 await update($, band, state => {
100 const prs = addNew(state.prs, found, pr => pr)
101 const isHidden = reveal ? false : state.isHidden
102 return prs === state.prs && isHidden === state.isHidden ? state : { prs, isHidden }
103 })
104 await addToIndex($, found)
105}
106
107export type GhPr = { number: number; state: string; title: string; reviewDecision: string }
108
109const HOST = /^https:\/\/([^/]+)\//
110
111// `host/owner/name`, the form `gh --repo` takes for any host.
112const repoKey = (pr: CreatedPr) => `${HOST.exec(pr.url)?.[1] ?? 'github.com'}/${pr.repo}`
113
114const GH_FIELDS = 'number,state,title,reviewDecision'
115
116const runGh = async <T,>($: EngineInterface, argv: string[]): Promise<T | undefined> => {
117 try {
118 const ran = await $.process.run(['gh', ...argv, '--json', GH_FIELDS])
119 return ran.exitCode === 0 ? (JSON.parse(ran.stdout) as T) : undefined
120 } catch {
121 return undefined
122 }
123}
124
125// The author's recent PRs in each repo the index mentions, plus any indexed PR older than
126// those; a repo gh could not list maps to undefined.
127const fetchGhPrs = async ($: EngineInterface, index: readonly IndexedPr[]) => {
128 const byRepo = new Map<string, GhPr[] | undefined>()
129 for (const key of new Set(index.map(repoKey))) {
130 byRepo.set(key, await runGh<GhPr[]>($, ['pr', 'list', '--repo', key, '--author', '@me', '--state', 'all', '--limit', '500']))
131 }
132 for (const pr of index) {
133 const listed = byRepo.get(repoKey(pr))
134 if (listed !== undefined && !listed.some(gh => gh.number === pr.number)) {
135 const viewed = await runGh<GhPr>($, ['pr', 'view', pr.url])
136 if (viewed !== undefined) {
137 listed.push(viewed)
138 }
139 }
140 }
141 return byRepo
142}
143
144const ghPrOf = (byRepo: ReadonlyMap<string, GhPr[] | undefined>, pr: CreatedPr) =>
145 byRepo.get(repoKey(pr))?.find(gh => gh.number === pr.number)
146
147const isDone = (gh: GhPr | undefined) => gh?.state === 'MERGED' || gh?.state === 'CLOSED'
148
149const clip = (text: string, max = 60) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
150
151export const formatIndex = (index: readonly IndexedPr[], byRepo: ReadonlyMap<string, GhPr[] | undefined>) => {
152 if (index.length === 0) {
153 return 'session-prs: no PRs recorded yet'
154 }
155 const lines: string[] = []
156 const open = index.filter(pr => !isDone(ghPrOf(byRepo, pr)))
157 const done = index.filter(pr => isDone(ghPrOf(byRepo, pr)))
158
159 lines.push(`Open (${open.length})`)
160 for (const pr of open) {
161 const gh = ghPrOf(byRepo, pr)
162 const status = gh === undefined ? 'unknown' : gh.reviewDecision || gh.state
163 lines.push(` ${pr.repo}#${pr.number} [${status}] ${clip(gh?.title ?? '')}`.trimEnd())
164 lines.push(` claude --resume ${pr.sessionId}`)
165 lines.push(` ${pr.cwd}${pr.branch ? ` (${pr.branch})` : ''}`)
166 }
167
168 const unrecorded = [...byRepo].flatMap(([key, prs]) =>
169 (prs ?? [])
170 .filter(gh => gh.state === 'OPEN' && !index.some(pr => repoKey(pr) === key && pr.number === gh.number))
171 .map(gh => ({ repo: key.split('/').slice(1).join('/'), gh })),
172 )
173 if (unrecorded.length > 0) {
174 lines.push('', `Open, no recorded session (${unrecorded.length})`)
175 for (const { repo, gh } of unrecorded) {
176 lines.push(` ${repo}#${gh.number} ${clip(gh.title)}`, ` claude --from-pr ${gh.number}`)
177 }
178 }
179
180 if (done.length > 0) {
181 lines.push('', `Merged or closed (${done.length}); /session-prs prune drops them`)
182 for (const pr of done) {
183 lines.push(` ${pr.repo}#${pr.number} [${ghPrOf(byRepo, pr)?.state}] ${pr.cwd}`)
184 }
185 }
186 return lines.join('\n')
187}
188
189// Picks up PRs created before the plugin loaded, such as earlier in a resumed session.
190const scanTranscript = async ($: EngineInterface) => {
191 const found = (await $.session.messages()).flatMap(message =>
192 message.toolUses.flatMap(use =>
193 use.isError ? [] : prsFromToolUse(use.tool, use.input, use.result, use.text),
194 ),
195 )
196 await record($, found, false)
197}
198
199export const register: Register = on => {
200 let hasScanned = false
201
202 on('tool.call', async ($, e, next) => {
203 const ran = await next(e)
204 if (ran.deny === undefined && !ran.isError) {
205 await record($, prsFromToolUse(e.tool, e, ran.result, ran.text), true)
206 }
207 return ran
208 })
209
210 on('session.start', async ($, e, next) => {
211 await $.command.register({
212 name: 'session-prs',
213 description:
214 'Show or hide the PRs created in this session; `list` lists every recorded PR with its session, `prune` drops merged and closed ones',
215 })
216 await scanTranscript($)
217 return next(e)
218 })
219
220 // A resumed transcript may not be readable yet at session.start, so scan once more.
221 on('prompt.submit', async ($, e, next) => {
222 if (!hasScanned) {
223 hasScanned = true
224 await scanTranscript($)
225 }
226 return next(e)
227 })
228
229 on('command.run', { command: 'session-prs' }, async ($, e) => {
230 const action = e.args.trim()
231 if (action === '') {
232 const { isHidden } = await update($, band, state => ({ ...state, isHidden: !state.isHidden }))
233 return { text: isHidden ? 'session-prs: hidden' : 'session-prs: shown' }
234 }
235 if (action !== 'list' && action !== 'prune') {
236 return { text: 'session-prs: usage: /session-prs [list|prune]' }
237 }
238
239 const index = await readIndex($)
240 const byRepo = await fetchGhPrs($, index)
241 if (action === 'prune') {
242 const kept = index.filter(pr => !isDone(ghPrOf(byRepo, pr)))
243 await $.store.set(INDEX_KEY, kept)
244 return { text: `session-prs: dropped ${index.length - kept.length} merged or closed PRs` }
245 }
246 return { text: formatIndex(index, byRepo) }
247 })
248
249 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
250 const { prs, isHidden } = await read($, band)
251 if (e.props.hasSurvey || prs.length === 0 || isHidden) {
252 return next(e)
253 }
254
255 const { Box, Button, Link, Text } = $.ui.resolve(e)
256
257 return (
258 <Box flexDirection="column">
259 <Box>
260 <Text dimColor>PRs this session ({prs.length}) </Text>
261 <Button
262 key="hide"
263 label="Hide"
264 onPress={() => update($, band, state => ({ ...state, isHidden: true }))}
265 />
266 </Box>
267 {prs.map(pr => (
268 <Box key={pr.url}>
269 <Text>• </Text>
270 <Link href={pr.url} label={`${pr.repo}#${pr.number}`} />
271 </Box>
272 ))}
273 </Box>
274 )
275 })
276}
277types/index.d.ts 18 lines1export type CreatedPr = { url: string; repo: string; number: number }
2
3export type SessionPrs = { prs: CreatedPr[]; isHidden: boolean }
4
5// A PR in the index kept across sessions, with the session that created it.
6export type IndexedPr = CreatedPr & {
7 sessionId: string
8 cwd: string
9 branch: string | null
10 recordedAt: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'session-prs': { band: SessionPrs }
16 }
17}
18