Toggle your open GitHub PRs above the prompt; focus with Ctrl+X then Tab, then use arrows and Enter to open one in the browser.

A Claude Code plugin marketplace containing plugins for session search/analytics, skill evaluation, Claude Code mod development, and a PR picker mod.
| Plugin | Version | Description |
|---|---|---|
| cass | 0.3.0 | Cross-agent session search, context, analytics, export, learnings, and session resume powered by CASS CLI |
| skill-eval | 1.0.0 | Automated skill/prompt/tool evaluation and improvement via session log analysis and autoresearch optimization |
| mod-forge | 0.2.0 | Build and verify Claude Code mods in isolated child processes, never in the live session (Claude Code 2.1.287+) |
| pr-pane | 0.2.0 | /prs toggles a band above the prompt with your open GitHub PRs and review state; arrows and Enter open one in the browser (Claude Code 2.1.287+, needs gh) |
claude plugin marketplace add https://github.com/ASRagab/asragab-claude-marketplace
# Install by name (use plugin@marketplace to disambiguate)
claude plugin install cass
claude plugin install skill-eval
claude plugin install mod-forge
claude plugin install pr-pane
bun run test, first run bun install in plugins/skill-eval/scripts (installs @anthropic-ai/sdk).gh), logged in with gh auth login (required by pr-pane)Cross-agent session search, context loading, token analytics, session export, session resume, and learning synthesis powered by CASS (Coding Agent Session Search). Searches across Claude Code, Codex, Cursor, Gemini CLI, Copilot, and 14+ other agents.
Recipes default to token-efficient output (--robot-format toon --fields summary --max-tokens 1600). The plugin's SessionStart hook surfaces the CASS index health, recommended next action, and a CASS_OUTPUT_FORMAT=toon advisory.
/cass:session-searchSearch across all indexed coding agent sessions. Supports lexical (BM25), semantic (vector), and hybrid search modes.
cass search "authentication flow" --mode hybrid --robot-format toon --fields summary --max-tokens 1600 --limit 10
cass search "error" --days 30 --aggregate agent --limit 1 --max-content-length 100 --robot-format toon
/cass:session-contextLoad relevant past session context for the current task, file, or project.
cass sessions --current --robot-format toon
cass timeline --since 7d --json --group-by day
/cass:session-analyticsAnalyze session history for usage patterns, token consumption, and tool efficiency.
cass analytics tokens --days 7 --group-by day --json
cass analytics tools --limit 20 --json
/cass:session-exportExport sessions to markdown, text, JSON, HTML, or self-contained encrypted HTML.
cass export <session_path> -o conversation.md
cass export-html <session_path> --encrypt --password "secret" --filename report.html
/cass:session-learningsExtract patterns, recurring issues, and actionable lessons from past sessions.
cass search "error fix bug" --mode hybrid --robot-format toon --fields summary --max-tokens 1600 --limit 20
cass analytics tools --limit 20 --json
/cass:session-resume ★ NEW v0.3.0Resolve a session into a ready-to-run launch command for its native harness (Claude Code, Codex, OpenCode, pi_agent, Gemini). Triggered by phrases like "resume that session", "pick up where I left off", "continue the X session".
cass resume "$(cass sessions --current --json | jq -r '.sessions[0].path')" --json
/cass:session-maintenanceDiagnose, repair, and maintain CASS installation, index, analytics, and remote sources.
cass health --json
cass doctor --fix
cass index --full --json
A four-stage pipeline for identifying friction in coding agent sessions and iteratively optimizing skills, prompts, and tools. Inspired by Karpathy's autoresearch pattern.
The stages run sequentially — each consumes the output of the previous stage.
/skill-eval:transcript-extract (M1)Extract structured events from Claude Code session transcripts into JSONL.
bun scripts/transcript-extract.ts --since 7d -o events.jsonl
/skill-eval:signal-classify (M2)Classify extracted events into friction, success, noise, and neutral categories using rule-based heuristics. No LLM calls required.
bun scripts/signal-classify.ts -i events.jsonl -o classified.jsonl
bun scripts/signal-classify.ts -i events.jsonl --stats
/skill-eval:target-identify (M3)Use LLM-as-judge to rank friction clusters by frequency, severity, and improvability.
bun scripts/target-identify.ts -i classified.jsonl --top 5 -o targets.jsonl
Requires ANTHROPIC_API_KEY environment variable.
/skill-eval:autoresearch-loop (M4)Iteratively generate and evaluate improvements against a target's eval criteria.
bun scripts/autoresearch-loop.ts -t targets.jsonl --max-rounds 20
Requires ANTHROPIC_API_KEY environment variable.
cd plugins/skill-eval
# 1. Extract events from recent sessions
bun scripts/transcript-extract.ts --since 7d -o events.jsonl
# 2. Classify friction signals
bun scripts/signal-classify.ts -i events.jsonl -o classified.jsonl
# 3. Identify top optimization targets
bun scripts/target-identify.ts -i classified.jsonl --top 5 -o targets.jsonl
# 4. Run autoresearch loop on the top target
bun scripts/autoresearch-loop.ts -t targets.jsonl --max-rounds 10
Build and verify Claude Code mods without touching the session you are working in. Claude Code only, version 2.1.287 or later.
The build-claude-mod skill sends an agent through scripts/run-mod.sh, which runs a mod through five fail-fast stages (validate, test, headless, typecheck, interactive) in separate processes under a harness-owned CLAUDE_CONFIG_DIR. See plugins/mod-forge/README.md for stages, evidence files, and exit codes.
Requires jq. Live stages also need ANTHROPIC_API_KEY, the interactive stage needs tmux, and the type check needs a TypeScript compiler.
# Offline test: no model calls, no API key
bun run test:mod-forge
# Live test: starts real claude children and makes real model calls
# (about $0.01 to $0.03 per headless run). Needs ANTHROPIC_API_KEY and tmux.
bash plugins/mod-forge/tests/live.test.sh
A Claude Code mod for keeping your open pull requests one keystroke away. Claude Code only, version 2.1.287 or later.
/prs toggles a band above the prompt listing your open PRs across repositories. The whole PR description carries its status color: ✗ unresolved comments or requested changes in red, ✓ approved in blue, ● needs review in orange, and ○ draft in dim text. Draft takes precedence; unresolved review threads take precedence over approval. Resolved threads and historical comments do not make a PR red.
Press Ctrl+X, then Tab to focus the band; arrows or Tab move between controls and Enter opens a PR in your browser (open on macOS, xdg-open on Linux). While the band has focus, r sorts by repository, s by status, and d by last-updated date. One sort mode is active at a time; repository/status groups use newest-updated first. The default status order is feedback, approved, needs review, draft. Up to eight rows appear per page; p/n switch pages. If other band output makes the drawing overflow, arrows scroll and Tab still moves focus. Esc returns to the prompt and leaves the band visible; /prs hides it. The band sets no background color and yields while a survey is active.
Dim horizontal rules above and below the PR content visually separate it while preserving the default background. Smaller bands reduce the page size to leave room for the rules.
It needs no configuration. The GitHub user is whoever gh is logged in as. Background polling runs at startup, every 60 seconds, and when you show the band; updates normally appear within a minute plus API latency. The header shows the open PR count and last successful refresh time. A failed refresh keeps the last good list and shows the error in the band. If gh is missing or logged out, it says to run gh auth login. The list includes the 100 most recently updated PRs; the header shows the true total when there are more.
# Run the mod's tests (no network, no API key)
claude plugin test plugins/pr-pane
claude plugin uninstall cass
claude plugin uninstall skill-eval
claude plugin uninstall mod-forge
claude plugin uninstall pr-pane
# To remove the marketplace itself
claude plugin marketplace remove asragab-claude-marketplace
MIT
hooks/register.tsx 159 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SortBy, View } from '../types'
5import { COLOR, GLYPH, QUERY, SHORT, THREADS_QUERY, counts, parse, row, sorted } from './prs'
6import type { PullRequestNode, ThreadPage } from './prs'
7
8const REFRESH_MS = 60_000
9const view = atom({ plugin: 'pr-pane', key: 'view' } as const, { prs: [], total: 0 } as View)
10const visible = atom({ plugin: 'pr-pane', key: 'visible' } as const, false)
11const sort = atom({ plugin: 'pr-pane', key: 'sort' } as const, 'status' as SortBy)
12const page = atom({ plugin: 'pr-pane', key: 'page' } as const, 0)
13
14const gh = async ($: EngineInterface, args: string[], failure: string): Promise<string> => {
15 const ran = await $.process
16 .run(['gh', ...args], { timeoutMs: 20_000 })
17 .catch(() => undefined)
18 if (!ran) throw new Error('gh could not run (not installed, or no answer in 20s): install the GitHub CLI, then run `gh auth login`')
19 if (ran.exitCode !== 0) throw new Error(`${failure}: ${ran.stderr.trim().split('\n')[0]}`)
20 return ran.stdout
21}
22
23let isBusy = false
24
25const refresh = async ($: EngineInterface): Promise<void> => {
26 if (isBusy) return
27 isBusy = true
28 try {
29 const login = (
30 await gh($, ['api', 'user', '--jq', '.login'], 'gh is not authenticated, run `gh auth login`')
31 ).trim()
32 const out = await gh($, ['api', 'graphql', '-f', `query=${QUERY}`], 'gh could not list PRs')
33 const response = JSON.parse(out)
34 for (const n of response.data.search.nodes as (PullRequestNode | null)[]) {
35 if (!n || n.isDraft || n.reviewDecision === 'CHANGES_REQUESTED') continue
36 let threads = n.reviewThreads
37 while (!threads.nodes.some(t => t?.isResolved === false) && threads.pageInfo.hasNextPage) {
38 const after = threads.pageInfo.endCursor
39 if (!after) throw new Error('GitHub returned an incomplete review thread page')
40 const more = await gh($, ['api', 'graphql', '-f', `query=${THREADS_QUERY}`, '-f', `id=${n.id}`, '-f', `after=${after}`], 'gh could not list review threads')
41 const next = JSON.parse(more).data?.node?.reviewThreads as ThreadPage | undefined
42 if (!next || (next.pageInfo.hasNextPage && next.pageInfo.endCursor === after)) {
43 throw new Error('GitHub returned an incomplete review thread page')
44 }
45 threads = next
46 }
47 n.reviewThreads = threads
48 }
49 const prs = parse(JSON.stringify(response))
50 const count = response.data.search.issueCount as number
51 const updatedAt = await $.clock.now()
52 await update($, view, () => ({ login, prs, total: count, updatedAt }))
53 } catch (err) {
54 const error = err instanceof Error ? err.message : String(err)
55 await update($, view, v => ({ ...v, error }))
56 } finally {
57 isBusy = false
58 }
59}
60
61// ponytail: macOS `open`, then Linux `xdg-open`; no Windows.
62const openUrl = async ($: EngineInterface, url: string): Promise<void> => {
63 if (!url.startsWith('https://')) return
64 for (const bin of ['open', 'xdg-open']) {
65 const ran = await $.process.run([bin, url]).catch(() => undefined)
66 if (ran?.exitCode === 0) return
67 }
68 $.ui.toast(`Could not open ${url}`)
69}
70
71export const register: Register = on => {
72 on('session.start', async ($, e, next) => {
73 const marker = await $.env.get('MOD_FORGE_MARKER')
74 if (marker) await $.fs.write(marker, 'loaded')
75 await $.command.register({ name: 'prs', description: 'Toggle open pull requests above the prompt' })
76 $.ui.status(undefined)
77 const started = await next(e)
78 void refresh($)
79 $.clock.every(REFRESH_MS, () => void refresh($))
80 return started
81 })
82
83 on('command.run', { command: 'prs' }, async $ => {
84 const isVisible = !(await read($, visible))
85 await update($, visible, () => isVisible)
86 if (isVisible) void refresh($)
87 return { text: isVisible ? 'PR list shown above the prompt.' : 'PR list hidden.' }
88 })
89
90 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91 if (e.props.hasSurvey || !(await read($, visible))) return next(e)
92 const theirs = await next(e)
93 const t = $.ui.resolve(e)
94 const { Box, Text, Button } = t
95 const v = await read($, view)
96 const by = await read($, sort)
97 const prs = sorted(v.prs, by)
98 const size = Math.max(1, Math.min(8, e.props.maxRows - 7 - (v.error ? 1 : 0)))
99 const pages = Math.max(1, Math.ceil(prs.length / size))
100 const current = Math.min(await read($, page), pages - 1)
101 const shown = prs.slice(current * size, (current + 1) * size)
102 const movePage = async (to: number) => {
103 await update($, page, () => to)
104 const first = prs[to * size]
105 if (first) void $.ui.focus({ requestId: e.requestId, key: `pr:${first.url}` }).catch(() => undefined)
106 }
107 const changeSort = async (to: SortBy) => {
108 await update($, page, () => 0)
109 await update($, sort, () => to)
110 }
111 const at = v.updatedAt ? new Date(v.updatedAt).toTimeString().slice(0, 5) : undefined
112 const rule = '─'.repeat(Math.max(0, e.props.bodyColumns))
113 return (
114 <Box flexDirection="column">
115 <Text dimColor wrap="truncate-end">{rule}</Text>
116 <Text bold>
117 {v.login ? `@${v.login}` : 'GitHub'} · {v.total} open
118 {v.total > v.prs.length ? ` (showing ${v.prs.length})` : ''}
119 {at ? ` · updated ${at}` : ''}
120 </Text>
121 <Box>
122 {counts(v.prs).map(c => (
123 <Text color={COLOR[c.state]} dimColor={c.state === 'draft'}>
124 {GLYPH[c.state]} {c.n} {SHORT[c.state]}{' '}
125 </Text>
126 ))}
127 </Box>
128 {v.error && <Text color="red">{v.error}</Text>}
129 {v.prs.length === 0 && !v.error && (
130 <Text dimColor>{v.updatedAt ? 'No open PRs.' : 'Loading...'}</Text>
131 )}
132 <Box columnGap={2}>
133 <Text dimColor>Sort:</Text>
134 {(['repo', 'status', 'date'] as const).map(mode => (
135 <Button key={`sort:${mode}`} plain hotkey={mode[0]} label={by === mode ? `[${mode}]` : mode} onPress={() => changeSort(mode)} />
136 ))}
137 <Text dimColor>{by === 'date' ? 'updated ↓' : 'updated ↓ within groups'}</Text>
138 </Box>
139 {shown.map((p, i) => (
140 <Box columnGap={1}>
141 <Button key={`pr:${p.url}`} plain label="›" autoFocus={i === 0 ? true : undefined} onPress={() => openUrl($, p.url)} />
142 <Text color={COLOR[p.state]} dimColor={p.state === 'draft'} wrap="truncate-end">{row(p, prs)}</Text>
143 </Box>
144 ))}
145 {prs.length > 0 && (
146 <Box columnGap={2}>
147 {current > 0 && <Button key="previous" plain hotkey="p" label="Previous" onPress={() => movePage(current - 1)} />}
148 <Text dimColor>Page {current + 1}/{pages}</Text>
149 {current + 1 < pages && <Button key="next" plain hotkey="n" label="Next" onPress={() => movePage(current + 1)} />}
150 </Box>
151 )}
152 <Text dimColor>Ctrl+X then Tab focuses · ↑/↓ or Tab move · Enter opens · r/s/d sort · p/n page · Esc prompt · /prs hides</Text>
153 <Text dimColor wrap="truncate-end">{rule}</Text>
154 {theirs}
155 </Box>
156 )
157 })
158}
159hooks/prs.ts 93 lines1import type { Pr, PrState, SortBy } from '../types'
2
3export const LIMIT = 100 // GraphQL search page maximum
4
5// `gh search prs --json` has no review decision; GraphQL search does.
6export const QUERY = `query{search(query:"is:pr is:open author:@me archived:false sort:updated-desc",type:ISSUE,first:${LIMIT}){issueCount nodes{... on PullRequest{id number title url isDraft reviewDecision updatedAt repository{nameWithOwner} reviewThreads(first:${LIMIT}){nodes{isResolved} pageInfo{hasNextPage endCursor}}}}}}`
7
8export const THREADS_QUERY = `query($id:ID!,$after:String!){node(id:$id){... on PullRequest{reviewThreads(first:${LIMIT},after:$after){nodes{isResolved} pageInfo{hasNextPage endCursor}}}}}`
9
10export const stateOf = (isDraft: boolean, decision: string | null, hasFeedback = false): PrState =>
11 isDraft
12 ? 'draft'
13 : decision === 'CHANGES_REQUESTED' || hasFeedback
14 ? 'has feedback'
15 : decision === 'APPROVED'
16 ? 'approved'
17 : 'needs review'
18
19export type ThreadPage = {
20 nodes: ({ isResolved: boolean } | null)[]
21 pageInfo: { hasNextPage: boolean; endCursor: string | null }
22}
23
24export type PullRequestNode = {
25 id: string
26 number: number
27 title: string
28 url: string
29 isDraft: boolean
30 reviewDecision: string | null
31 updatedAt: string
32 repository: { nameWithOwner: string }
33 reviewThreads: ThreadPage
34}
35
36export const total = (stdout: string): number => JSON.parse(stdout).data.search.issueCount
37
38// Status sorting puts feedback first and drafts last.
39export const ORDER: readonly PrState[] = ['has feedback', 'approved', 'needs review', 'draft']
40
41export const parse = (stdout: string): Pr[] =>
42 (JSON.parse(stdout).data.search.nodes as (PullRequestNode | null)[])
43 .filter((n): n is PullRequestNode => n !== null && !!n.url)
44 .map(n => ({
45 repo: n.repository.nameWithOwner,
46 number: n.number,
47 title: n.title,
48 url: n.url,
49 state: stateOf(n.isDraft, n.reviewDecision, n.reviewThreads?.nodes?.some(t => t?.isResolved === false)),
50 updatedAt: n.updatedAt,
51 }))
52
53export const sorted = (prs: readonly Pr[], by: SortBy): Pr[] =>
54 [...prs].sort((a, b) =>
55 (by === 'repo' ? a.repo.localeCompare(b.repo) : by === 'status' ? ORDER.indexOf(a.state) - ORDER.indexOf(b.state) : 0)
56 || Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
57 || a.repo.localeCompare(b.repo)
58 || a.number - b.number
59 || a.url.localeCompare(b.url),
60 )
61
62export const COLOR: Record<PrState, string | undefined> = {
63 'has feedback': 'red',
64 approved: 'blue',
65 'needs review': '#ffa500',
66 draft: undefined,
67}
68
69export const GLYPH: Record<PrState, string> = {
70 draft: '○',
71 'has feedback': '✗',
72 approved: '✓',
73 'needs review': '●',
74}
75
76export const SHORT: Record<PrState, string> = {
77 draft: 'draft',
78 'has feedback': 'comments/changes',
79 approved: 'approved',
80 'needs review': 'needs review',
81}
82
83export const counts = (prs: readonly Pr[]): { state: PrState; n: number }[] =>
84 ORDER.map(state => ({ state, n: prs.filter(p => p.state === state).length })).filter(c => c.n > 0)
85
86// The glyph carries the state (the header legend names it); the owner is dropped
87// unless two listed repos share a name.
88export const row = (p: Pr, all: readonly Pr[]): string => {
89 const name = p.repo.split('/')[1] ?? p.repo
90 const isAmbiguous = all.some(o => o.repo !== p.repo && o.repo.endsWith(`/${name}`))
91 return `${GLYPH[p.state]} ${isAmbiguous ? p.repo : name}#${p.number} ${p.title}`
92}
93types/index.d.ts 20 lines1export type PrState = 'draft' | 'has feedback' | 'approved' | 'needs review'
2
3export type SortBy = 'repo' | 'status' | 'date'
4
5export type Pr = { repo: string; number: number; title: string; url: string; state: PrState; updatedAt: string }
6
7export type View = {
8 login?: string
9 prs: Pr[]
10 total: number
11 updatedAt?: number
12 error?: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'pr-pane': { view: View; visible: boolean; sort: SortBy; page: number }
18 }
19}
20