SLOPSHOPPER

session-prs

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

newbandguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-prs
› fix the failing auth test and add an audit log call ⏺ 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 › /session-prs ⎿ session-prs: session-prs: hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-plugins

Personal Claude Code plugins.

/plugin marketplace add itkrt2y/claude-plugins

Plugins

idle-compact

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.

  • The timer starts when a turn you sent completes, and is cancelled by a new turn, a manual or automatic compaction, or the end of the session.
  • It compacts once and does not re-arm until you send another message.
  • A timer that fires 55 minutes or more after the turn (for example after the machine slept) does nothing, since the cache may already be cold.
  • Failed compactions are not retried.

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.

review-threads

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

rebase

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

circleci-triage

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

project-memory

  • A SessionStart hook injects ~/.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.
  • The 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

desktop-notify

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

session-prs

Lists the pull requests created in the current session in a band above the prompt, each linked as owner/repo#number.

  • A PR is picked up when 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.
  • At session start the plugin also scans the session's transcript (the newest 4096 messages), so PRs created before it loaded, such as earlier in a resumed session, are listed too. The scan reads the local transcript only and makes no model call.
  • PRs created any other way, for example in the browser, are not listed.
  • The list lasts for the session. /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:

  • Open PRs, each with its review state from gh and the claude --resume <session-id> command for the session that created it.
  • Your open PRs in the same repositories that have no recorded session, each with claude --from-pr <number>.
  • Merged and closed PRs, with the directory they were made in, as cleanup candidates. /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

Development

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"; done
Source 2 files
hooks/session-prs.tsx 277 lines
1import { 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}
277
types/index.d.ts 18 lines
1export 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