SLOPSHOPPER

PR Pilot

Shows the current branch's pull request above the prompt, with its checks and whether it can merge, toasts when checks finish, and merges it, or its whole…

newbandcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-pilot
› 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 › /merge ⎿ pr-pilot: No open pull request for this branch. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🧩 Claude Code Mods

Live panes, prompt bands, status line entries, toasts, and hooks for Claude Code, each shipped as a plugin.

Release CI License: MIT

🚀 Install

/plugin marketplace add tartinerlabs/claude-code-mods
/plugin install <mod> --marketplace tartinerlabs/claude-code-mods

🧰 Mods

| | Mod | Where it shows | In one line | | :-: | --- | --- | --- | | ⏸ | auto-resume | Status line, toast | Picks a turn back up once a usage limit resets | | 🏷 | auto-session-name | Session title | Names each session from its first prompt | | ⏱ | cache-clock | Band above the prompt | Counts down to the prompt cache going cold | | 📊 | limit-watch | Band above the prompt, toasts | Shows the 5-hour and weekly usage limits | | 🛫 | pr-pilot | Band above the prompt, toasts | Shows the branch's PR checks and merges it with /merge | | 🗜 | smart-compact | Toast | Compacts between turns, sooner at checkpoints or when the cache is cooling | | ⛅ | token-weather | Band above the prompt | Forecasts how full the context window is |


⏸ auto-resume

When a turn stops on a usage limit, waits until the limit resets and sends continue. Typing a prompt yourself or running /auto-resume cancel stops the wait.

⏸ auto-resume in 1h 12m                                    ← status line
Usage limit reset, resuming                                 ← toast

<sub>📁 plugins/auto-resume</sub>

🏷 auto-session-name

Names each session from its first prompt using Haiku and can give it a prompt bar colour, so it is easy to find with claude -r after a crash. Sessions already named with -n or /rename are left alone. Set the color option to red, blue, green, yellow, purple, orange, pink or cyan to colour named sessions; default leaves the colour alone.

> fix the flaky retry test in the upload worker
                                    → session titled "Fix flaky upload retry test"

<sub>📁 plugins/auto-session-name</sub>

⏱ cache-clock

Draws a countdown above the prompt to when the prompt cache goes cold, with the last request's hit rate. Once cold, it shows how much the next turn re-writes. Set the ttl option to 1h when extended caching is on.

cache ● 3:42 left · 96% hit                                 ← green, yellow under a minute
cache ○ cold · next turn re-writes 84.3k                    ← red

<sub>📁 plugins/cache-clock</sub>

📊 limit-watch

Draws the 5-hour and weekly usage limits above the prompt with live reset countdowns, green under 60%, yellow to 80% and red past it. Toasts once a window passes 80% and again at 95%. Any other window the API reports, such as a gateway spend limit, shows under its own name.

5h 42% · 2h17m  7d 81% · 3d4h                               ← band
7d limit at 81%, resets in 3d4h                             ← toast

<sub>📁 plugins/limit-watch</sub>

🛫 pr-pilot

Draws the current branch's open pull request above the prompt with its checks and whether it can merge, refreshed each minute and after every turn. Toasts when a run of checks finishes. /merge merges it with gh and deletes the branch, but only once every check has passed and nothing blocks it. On a gh-stack layer the band shows its place in the stack, and /merge merges the whole stack with gh stack merge once every unmerged layer has passed its checks and has no unpushed commits, then switches to the trunk and deletes every layer's branch, local and remote, once all of them have merged. Set the method option to merge (default), squash or rebase. Needs the GitHub CLI, signed in.

PR #42  ◐ 3/5 checks  waiting                                ← band
PR #42  ✗ 1 failing: SonarCloud  blocked                     ← red
PR #42  ✓ 5/5 checks  ready · /merge                         ← green
PR #42  stack 2/3  ✓ 5/5 checks  ready · /merge              ← a gh-stack layer
PR #42 checks passed, ready to /merge                        ← toast

<sub>📁 plugins/pr-pilot</sub>

🗜 smart-compact

Compacts the conversation between turns, never during one, weighing several factors against the context's fill. Past the threshold option (60, 70 default, or 80%) it compacts about ten seconds after a turn ends. It compacts sooner when compacting is cheapest or most worth it:

  • Checkpoints, 20 points lower: a turn that committed, opened or merged a PR (gh pr, gh stack), had a plan approved, finished its todo list, or switched branch or worktree. The summary is told what to keep for each, such as the approved plan verbatim. A checkpoint counts only until the next turn starts. Set checkpoints to off to ignore them.
  • Cache cooling, 20 points lower in the last minute before the prompt cache goes cold, so the summary still reads the conversation from cache; 10 lower once it is cold, so the next turn re-writes a short prefix instead of a long one.
  • 5-hour limit, 10 points lower while it is at 80% or more.

Nothing takes it under the floor option (30, 40 default, or 50%), and it tries at most once per idle stretch. Set ttl to 1h when extended prompt caching is on.

Compacted at 52%: committed                                  ← toast
Compacted at 55%: cache cooling                              ← toast
Compacted at 74%: context high, 5h limit at 86%              ← toast

<sub>📁 plugins/smart-compact</sub>

⛅ token-weather

Draws a forecast of the context window above the prompt, with tokens used, a sparkline of the last 12 turns and the change since the last turn.

☁  Cloudy  38% of context  76.4k / 200k   last turns ▁▂▂▃▄▄▅▆  ▲ +4.1k last turn

| ☀ Clear | ☁ Cloudy | ☂ Showers | ☇ Storm | ↯ Compact soon | | :-: | :-: | :-: | :-: | :-: | | under 25% | under 50% | under 75% | under 90% | 90% and up |

<sub>📁 plugins/token-weather</sub>


MIT © Tartiner Labs

Source 2 files
hooks/register.tsx 235 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Checks, Pr, Stack } from '../types'
5
6const FIELDS = 'number,url,headRefOid,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup'
7const FAILED = ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
8
9// Held by the host, so a hot reload keeps the band and does not toast the same result twice.
10const current = atom({ plugin: 'pr-pilot', key: 'pr' } as const, null as Pr | null)
11
12/** Drops control characters, so a check name or gh output cannot carry terminal escapes. */
13export const clean = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
14
15type Rollup = { __typename?: string; name?: string; context?: string; status?: string; conclusion?: string; state?: string }
16
17/** Tallies `statusCheckRollup`, which mixes check runs (`status`, `conclusion`) and commit statuses (`state`). */
18export function tally(rollup: readonly Rollup[]): Checks {
19  const checks: Checks = { total: rollup.length, passed: 0, pending: 0, failed: [] }
20  for (const check of rollup) {
21    const result = check.__typename === 'StatusContext' ? check.state : check.status === 'COMPLETED' ? check.conclusion : 'PENDING'
22    if (FAILED.includes(result ?? '')) checks.failed.push(clean(check.name ?? check.context ?? '?'))
23    else if (result === 'PENDING' || result === 'EXPECTED' || !result) checks.pending += 1
24    else checks.passed += 1
25  }
26
27  return checks
28}
29
30/** True once the pull request can merge as is: open, checks green, nothing blocking it. */
31export function isReady(pr: Pr) {
32  return !pr.isDraft && pr.mergeable === 'MERGEABLE' && pr.checks.failed.length === 0 && pr.checks.pending === 0 && ['CLEAN', 'HAS_HOOKS'].includes(pr.mergeStateStatus)
33}
34
35/** The band's checks entry and its colour. */
36export function checksEntry({ total, passed, pending, failed }: Checks): [string, string] {
37  if (total === 0) return ['no checks', 'gray']
38  if (failed.length) return [`✗ ${failed.length} failing: ${failed.join(', ')}`, 'red']
39  if (pending) return [`◐ ${passed}/${total} checks`, 'yellow']
40
41  return [`✓ ${total}/${total} checks`, 'green']
42}
43
44/** The band's merge entry and its colour. */
45export function mergeEntry(pr: Pr): [string, string] {
46  if (pr.isDraft) return ['draft', 'gray']
47  if (pr.mergeable === 'CONFLICTING') return ['conflicts', 'red']
48  if (pr.mergeStateStatus === 'BEHIND') return ['behind base', 'yellow']
49  if (pr.mergeStateStatus === 'BLOCKED') return ['blocked', 'yellow']
50  if (isReady(pr)) return ['ready · /merge', 'green']
51
52  return ['waiting', 'gray']
53}
54
55type StackView = { trunk: string; currentBranch: string; branches: { name: string; isMerged: boolean; pr?: { number: number } }[] }
56
57/** The current branch's place among the unmerged layers of `gh stack view --json`, or null when it is not one. */
58export function parseStack(view: StackView): Stack | null {
59  const layers = view.branches.filter(b => !b.isMerged).map(b => ({ branch: b.name, pr: b.pr?.number }))
60  const index = layers.findIndex(l => l.branch === view.currentBranch)
61
62  return index < 0 ? null : { position: index + 1, total: layers.length, trunk: view.trunk, layers }
63}
64
65async function fetchStack($: EngineInterface): Promise<Stack | null> {
66  // Exits non-zero off a stack, and where the gh-stack extension is not installed.
67  const ran = await $.process.run(['gh', 'stack', 'view', '--json'], { timeoutMs: 15_000 }).catch(() => null)
68  if (!ran || ran.exitCode !== 0) return null
69
70  try {
71    return parseStack(JSON.parse(ran.stdout))
72  } catch {
73    return null
74  }
75}
76
77async function fetchPr($: EngineInterface, number?: number): Promise<Pr | null> {
78  const argv = ['gh', 'pr', 'view', ...(number ? [String(number)] : []), '--json', FIELDS]
79  const ran = await $.process.run(argv, { timeoutMs: 15_000 }).catch(() => null)
80  if (!ran || ran.exitCode !== 0) return null
81  const view = JSON.parse(ran.stdout)
82  if (view.state !== 'OPEN') return null
83
84  return {
85    number: view.number,
86    url: view.url,
87    head: view.headRefOid,
88    isDraft: view.isDraft,
89    mergeable: view.mergeable,
90    mergeStateStatus: view.mergeStateStatus,
91    checks: tally(view.statusCheckRollup ?? []),
92  }
93}
94
95async function refresh($: EngineInterface) {
96  const pr = await fetchPr($)
97  const stack = pr && (await fetchStack($))
98  if (pr && stack) pr.stack = stack
99  const was = await read($, current)
100  await update($, current, () => pr)
101
102  // Toast when the same pull request's checks finish, not on first sight.
103  if (!pr || was?.number !== pr.number || !was.checks.pending || pr.checks.pending) return
104  $.ui.toast(
105    pr.checks.failed.length
106      ? `PR #${pr.number} checks failed: ${pr.checks.failed.join(', ')}`
107      : `PR #${pr.number} checks passed${isReady(pr) ? ', ready to /merge' : ''}`,
108    { timeoutMs: 8000 },
109  )
110}
111
112/** True when `branch` is at `head` locally, so merging and deleting it loses no unpushed commits. */
113async function isPushed($: EngineInterface, branch: string, head: string) {
114  const local = await $.process.run(['git', 'rev-parse', '--verify', `refs/heads/${branch}^{commit}`]).catch(() => null)
115
116  return local?.exitCode === 0 && local.stdout.trim() === head
117}
118
119/**
120 * Merges the whole stack with `gh stack merge`, after checking every unmerged layer, since it is all
121 * or nothing and has no head pin. It runs without an argument, as a number would be read as a stack
122 * number before a pull request number.
123 */
124async function mergeStack($: EngineInterface, pr: Pr, stack: Stack, method: string) {
125  const numbers: number[] = []
126  for (const layer of stack.layers) {
127    if (!layer.pr) return `${layer.branch} has no pull request yet: run gh stack submit first.`
128    const one = layer.pr === pr.number ? pr : await fetchPr($, layer.pr)
129    if (!one) return `PR #${layer.pr} (${layer.branch}) is not open.`
130    if (!isReady(one)) return `PR #${one.number} (${layer.branch}) is not ready to merge: ${checksEntry(one.checks)[0]}, ${mergeEntry(one)[0]}.`
131    if (!(await isPushed($, layer.branch, one.head))) return `${layer.branch} is not at PR #${one.number}'s head: push or pull first.`
132    numbers.push(one.number)
133  }
134
135  const ran = await $.process.run(['gh', 'stack', 'merge', '--yes', `--${method}`], { timeoutMs: 300_000 })
136  if (ran.exitCode !== 0) return `gh stack merge failed: ${clean((ran.stderr || ran.stdout).trim())}`
137
138  return `Merged stack: PR #${numbers.join(', #')}.${await deleteLayers($, stack)}`
139}
140
141/**
142 * Deletes every layer's branch, local and remote, and switches to the trunk, as `gh pr merge -d` does
143 * for one pull request, which `gh stack merge` has no flag for. It waits until every pull request has
144 * merged, since deleting a head branch while the stack sits in a merge queue would close it.
145 */
146async function deleteLayers($: EngineInterface, stack: Stack) {
147  for (const layer of stack.layers) {
148    const state = await $.process.run(['gh', 'pr', 'view', String(layer.pr), '--json', 'state', '--jq', '.state'], { timeoutMs: 15_000 }).catch(() => null)
149    if (state?.stdout.trim() !== 'MERGED') return ' Branches kept until every pull request has merged.'
150  }
151  const checkout = await $.process.run(['git', 'checkout', stack.trunk]).catch(() => null)
152  if (checkout?.exitCode !== 0) return ` Could not switch to ${stack.trunk}, so the branches were kept.`
153  await $.process.run(['git', 'pull', '--ff-only'], { timeoutMs: 60_000 }).catch(() => null)
154
155  const kept: string[] = []
156  for (const { branch } of stack.layers) {
157    // Fails harmlessly when the repository already deleted the head branch on merge.
158    await $.process.run(['gh', 'api', '-X', 'DELETE', `repos/{owner}/{repo}/git/refs/heads/${branch}`], { timeoutMs: 15_000 }).catch(() => null)
159    const deleted = await $.process.run(['git', 'branch', '-D', branch]).catch(() => null)
160    if (deleted?.exitCode !== 0) kept.push(branch)
161  }
162
163  return kept.length ? ` Switched to ${stack.trunk}, but could not delete ${kept.join(', ')} locally.` : ` Deleted ${stack.layers.map(l => l.branch).join(', ')} and switched to ${stack.trunk}.`
164}
165
166export const register: Register = (on, options) => {
167  const method = typeof options.method === 'string' ? options.method : 'merge'
168
169  on('session.start', async ($, e, next) => {
170    const result = await next(e)
171    await $.command.register({ name: 'merge', description: "Merge this branch's pull request, or its whole stack, once checks pass" })
172    await refresh($)
173    $.clock.every(60_000, () => refresh($))
174
175    return result
176  })
177
178  // A turn may have pushed or opened a pull request.
179  on('turn.complete', async ($, e, next) => {
180    const result = await next(e)
181    if (e.agentId === undefined) void refresh($)
182
183    return result
184  })
185
186  on('command.run', { command: 'merge' }, async $ => {
187    await refresh($)
188    const pr = await read($, current)
189    if (!pr) return { text: 'No open pull request for this branch.' }
190    if (pr.stack) {
191      const text = await mergeStack($, pr, pr.stack, method)
192      await refresh($)
193
194      return { text }
195    }
196    if (!isReady(pr)) return { text: `PR #${pr.number} is not ready to merge: ${checksEntry(pr.checks)[0]}, ${mergeEntry(pr)[0]}.` }
197
198    // --delete-branch drops the local branch, so refuse while it holds commits the PR does not.
199    const local = await $.process.run(['git', 'rev-parse', 'HEAD']).catch(() => null)
200    if (local?.stdout.trim() !== pr.head) return { text: `This branch is not at PR #${pr.number}'s head: push or pull first.` }
201
202    // Pinned to the head that was checked, so a push since then is not merged unchecked.
203    const ran = await $.process.run(['gh', 'pr', 'merge', String(pr.number), `--${method}`, '--delete-branch', '--match-head-commit', pr.head], { timeoutMs: 120_000 })
204    await refresh($)
205
206    return { text: ran.exitCode === 0 ? `Merged PR #${pr.number}.` : `gh pr merge failed: ${clean((ran.stderr || ran.stdout).trim())}` }
207  })
208
209  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210    const pr = await read($, current)
211
212    if (e.props.hasSurvey || !pr) {
213      return next(e)
214    }
215
216    const { Box, Text } = $.ui.resolve(e)
217    const [checks, checksColour] = checksEntry(pr.checks)
218    const [merge, mergeColour] = mergeEntry(pr)
219    // Stack above whatever the plugins beneath draw, so other bands stay.
220    const below = await next(e)
221
222    return (
223      <Box flexDirection="column">
224        <Box flexDirection="row" paddingX={1} gap={2}>
225          <Text dimColor>PR #{pr.number}</Text>
226          {pr.stack && <Text dimColor>stack {pr.stack.position}/{pr.stack.total}</Text>}
227          <Text color={checksColour} wrap="truncate-end">{checks}</Text>
228          <Text color={mergeColour}>{merge}</Text>
229        </Box>
230        {below}
231      </Box>
232    )
233  })
234}
235
types/index.d.ts 26 lines
1/** How the pull request's checks stand: `failed` names the failing ones. */
2export type Checks = { total: number; passed: number; pending: number; failed: string[] }
3
4/** The unmerged layers of a gh-stack stack, bottom first, where the current branch sits among them, and the trunk they merge into. */
5export type Stack = { position: number; total: number; trunk: string; layers: { branch: string; pr?: number }[] }
6
7/** The open pull request for the current branch, as `gh pr view` last reported it. */
8export type Pr = {
9  number: number
10  url: string
11  /** The head commit the checks ran on. */
12  head: string
13  isDraft: boolean
14  mergeable: string
15  mergeStateStatus: string
16  checks: Checks
17  /** Set when the branch is a layer of a gh-stack stack. */
18  stack?: Stack
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'pr-pilot': { pr: Pr | null }
24  }
25}
26