Drafts App Store release notes for Xcode projects when the version is bumped, into fastlane metadata or CHANGELOG.md, and shows the version above the prompt.

Live panes, prompt bands, status line entries, toasts, and hooks for Claude Code, each shipped as a plugin.
/plugin marketplace add tartinerlabs/claude-code-mods
/plugin install <mod> --marketplace tartinerlabs/claude-code-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 and keeps the name current as the work moves on | | ⏱ | 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 and the session's cost | | 🛫 | pr-pilot | Band above the prompt, toasts | Shows the branch's PR checks and merges it with /merge | | 📝 | release-notes | Band above the prompt, toast | Drafts App Store release notes for Xcode projects when the version is bumped | | 📱 | resource-watch | Band above the prompt, toast | Shows which session is using each simulator, Claude Design project or the Xcode app, and messages the other session on a clash | | 🗜 | 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 |
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>
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. When the first prompt is short, such as continue or look at this, it titles the session again from the last four prompts on the fourth. Every eighth prompt it checks the title against the last four prompts and renames the session only when the work has moved to a different task; small shifts keep the title. Sessions already named with -n or /rename are left alone, and a rename stops any re-title. 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>
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>
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. The session's cost so far follows the limits.
🕔 5h 42% · 2h17m 📅 7d 81% · 3d4h 💰 $2.36 ← band
7d limit at 81%, resets in 3d4h ← toast
<sub>📁 plugins/limit-watch</sub>
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>
In an Xcode project, draws the app's MARKETING_VERSION and build above the prompt with the commits since the last release. When a turn, or anything between sessions, changes the version, Haiku drafts App Store "What's New" notes from those commits and a toast says where they went. With fastlane they go to fastlane/metadata/<locale>/release_notes.txt for the default locale, else en-US, else the first. Without fastlane they become a ## <version> section in CHANGELOG.md, and a section that is already there is left alone. The last release is the newest tag, or the commit that set the previous version when the repository has no tags. The first time it sees a repository it only records the version. /draft-release-notes drafts the notes again, replacing what is there.
📝 v2.4.0 (41) 12 commits since v2.3.0 notes drafted ← band
Release notes for 2.4.0 drafted in fastlane/metadata/en-US/release_notes.txt ← toast
<sub>📁 plugins/release-notes</sub>
Shows which Claude Code session is using each shared thing on the machine, so parallel agents do not fight over one. Booted Apple simulators and Claude Design projects are the baseline; the Xcode app sits on top.
simctl or xcodebuild names it by UDID or name, or uses booted or an Xcode MCP run while only one is booted; commands that only look, as simctl list or xcodebuild -showdestinations, do not count.claude-design tool call on it.A hold lasts the hold option's minutes (10 by default) after the last call, and ends with the session. Sessions share their holds as files in ~/.claude/resource-watch/, labelled by their project's folder. A simulator call on one another session holds is refused the first time: the holding session is asked, and the agent is told to wait for its reply, then repeat the call to go ahead, and never to switch to or create another simulator without asking you. The holder tells the waiting sessions once it is done: after StopProject or DeviceInteractionEndSession, when its hold runs out, or when it ends. For a Claude Design project or the Xcode app nothing is refused: it toasts, messages the holding session so its agent can reply asking it to wait, and tells its own agent who holds it; a session that ends tells the sessions it shared with.
📱 iPhone 18 Pro (iOS 27.2) · this session 🛠 the Xcode app · ⚠ also the web session ← band
iPhone 18 Pro is in use by the web session; asked it, waiting ← toast
<sub>📁 plugins/resource-watch</sub>
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:
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.Nothing takes it under the floor option (30, 40 default, or 50%), and it tries at most once per idle stretch. The ttl option defaults to auto: 1h on a subscription, 5m on an API key or cloud provider. Set it to 5m while a subscription draws on usage credits, or to 1h when you set a one-hour cache yourself.
Set continue to tasks to carry on after compacting while todos or tasks are still open. It sends Continue with the remaining tasks., unless the turn was interrupted or ended on a question.
Compacted at 52%: committed ← toast
Compacted at 55%: cache cooling ← toast
Compacted at 74%: context high, 5h limit at 86% ← toast
Compacted at 75%: context high; continuing ← toast
<sub>📁 plugins/smart-compact</sub>
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>
hooks/register.tsx 263 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Release } from '../types'
5
6const PROMPT = `Write the App Store "What's New" text for version {version} of an Apple app from the commit subjects below, newest first.
7
8Write for the people who use the app, not its developers: one short line per change they would notice, each starting with "- ", grouped new features first, then improvements, then fixes. Leave out refactors, tests, CI, dependency bumps, version bumps and other internal work. If nothing is user-facing, reply "- Bug fixes and performance improvements." Reply with the lines only.
9
10{commits}`
11
12// App Store Connect's limit for "What's New".
13const MAX_NOTES = 4000
14const MAX_COMMITS = 200
15const FALLBACK = '- Bug fixes and performance improvements.'
16
17// Held by the host, so a hot reload keeps the band.
18const current = atom({ plugin: 'release-notes', key: 'release' } as const, null as Release | null)
19
20/** What the mod remembers per repository across sessions. */
21type Seen = { version: string; draftedFor?: string }
22
23type Scan = { root: string; subjects: string[]; release: Release }
24
25/** Compares dotted versions numerically, so 2.10 sorts after 2.9. */
26export function compareVersions(a: string, b: string) {
27 const left = a.split('.').map(Number)
28 const right = b.split('.').map(Number)
29 for (let i = 0; i < Math.max(left.length, right.length); i++) {
30 const diff = (left[i] || 0) - (right[i] || 0)
31 if (diff) return diff
32 }
33
34 return 0
35}
36
37const highest = (values: string[]) => values.sort(compareVersions).at(-1) ?? null
38
39/**
40 * The app's version and build from a `project.pbxproj` (`KEY = 1.0;`) or the JSON `project.xcproj`
41 * (`"KEY": "1.0"`). Targets can disagree (a test target left at 1.0), so the highest wins; anything but
42 * digits and dots, such as a value set from another build setting (`$(...)`), is skipped.
43 */
44export function parseVersions(project: string) {
45 const values = (key: string) =>
46 [...project.matchAll(new RegExp(`\\b${key}"?\\s*[=:]\\s*"?([^";,\\n]+)"?`, 'g'))].map(m => m[1]!.trim()).filter(v => /^\d+(\.\d+)*$/.test(v))
47
48 return { version: highest(values('MARKETING_VERSION')), build: highest(values('CURRENT_PROJECT_VERSION')) }
49}
50
51/** The fastlane locale the notes go to: `default` (deliver's fallback for every language), then en-US, then the first. */
52export function pickLocale(names: string[]) {
53 // Locale folders only, not review_information or trade_representative_contact_information.
54 const locales = names.filter(n => /^(default|[a-z]{2,3}(-[A-Za-z0-9]+)*)$/.test(n)).sort()
55
56 return locales.find(n => n === 'default') ?? locales.find(n => n === 'en-US') ?? locales[0] ?? null
57}
58
59const heading = (version: string) => new RegExp(`^## \\[?${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]?(?=[\\s(]|$)`, 'm')
60
61/** True when a changelog already holds a section for `version`. */
62export const hasSection = (changelog: string, version: string) => heading(version).test(changelog)
63
64/** Puts `notes` under a `## version` section: replacing that section's body, or above the newest section. */
65export function withSection(changelog: string, version: string, notes: string) {
66 const section = `## ${version}\n\n${notes}\n`
67 const found = heading(version).exec(changelog)
68 if (found) {
69 const rest = changelog.slice(found.index + found[0].length)
70 const next = rest.search(/^## /m)
71
72 return changelog.slice(0, found.index) + section + (next < 0 ? '' : `\n${rest.slice(next)}`)
73 }
74 const newest = changelog.search(/^## /m)
75 if (newest >= 0) return `${changelog.slice(0, newest)}${section}\n${changelog.slice(newest)}`
76
77 return `${changelog.trimEnd() || '# Changelog'}\n\n${section}`
78}
79
80/** Drops control characters but newlines, so a reply cannot carry terminal escapes, and holds it to the App Store limit. */
81export function cleanNotes(text: string) {
82 return text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '').trim().slice(0, MAX_NOTES).trim()
83}
84
85async function git($: EngineInterface, root: string, ...args: string[]) {
86 const ran = await $.process.run(['git', '-C', root, ...args], { timeoutMs: 15_000 }).catch(() => null)
87
88 return ran?.exitCode === 0 ? ran.stdout.trim() : null
89}
90
91/** The tag or commit the notes count from: the newest tag that is not this version's, else the commit that set the previous version. */
92/**
93 * True when `path`, or its folder while it does not exist yet, resolves inside the repository, so a
94 * symbolic link in a cloned repository cannot send a write elsewhere.
95 */
96async function isInside($: EngineInterface, root: string, path: string) {
97 const resolve = (p: string) => $.fs.stat(p, { resolve: true }).catch(() => undefined)
98 const realRoot = (await resolve(root))?.realPath
99 const own = await resolve(path)
100 // A dangling link has a stat but no realPath, and is refused.
101 const real = own ? own.realPath : (await resolve(path.slice(0, path.lastIndexOf('/'))))?.realPath
102
103 return realRoot !== undefined && real !== undefined && (real.startsWith(`${realRoot}/`) || (!own && real === realRoot))
104}
105
106async function findSince($: EngineInterface, root: string, project: string, version: string) {
107 let tag = await git($, root, 'describe', '--tags', '--abbrev=0')
108 // Tagged already, so count from the release before it.
109 if (tag && tag.replace(/^v/, '') === version) tag = await git($, root, 'describe', '--tags', '--abbrev=0', '--end-of-options', `${tag}^`)
110 if (tag) return tag
111
112 const bumps = (await git($, root, 'log', '-n', '50', '--format=%h', '-G', 'MARKETING_VERSION', '--', project))?.split('\n').filter(Boolean) ?? []
113 for (const sha of bumps) {
114 const then = await git($, root, 'show', `${sha}:${project}`)
115 if (then && parseVersions(then).version !== version) return sha
116 }
117
118 return null
119}
120
121async function findTarget($: EngineInterface, root: string, project: string) {
122 // fastlane sits beside the .xcodeproj, or at the repository root.
123 const beside = project.split('/').slice(0, -2).join('/')
124 for (const dir of [...new Set([beside, ''])]) {
125 const metadata = `${dir ? `${dir}/` : ''}fastlane/metadata`
126 const entries = await $.fs.list(`${root}/${metadata}`).catch(() => [])
127 const locale = pickLocale(entries.filter(e => e.kind === 'dir').map(e => e.name))
128 if (locale) return { target: `${metadata}/${locale}/release_notes.txt`, isFastlane: true }
129 }
130
131 return { target: 'CHANGELOG.md', isFastlane: false }
132}
133
134async function scan($: EngineInterface): Promise<Scan | null> {
135 const root = await git($, '.', 'rev-parse', '--show-toplevel')
136 if (!root) return null
137 const project = (await git($, root, 'ls-files', '*.xcodeproj/project.pbxproj', '*.xcodeproj/project.xcproj'))
138 ?.split('\n')
139 .filter(p => p && !/(^|\/)(Pods|Carthage)\//.test(p))
140 .sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length)[0]
141 if (!project) return null
142 const { version, build } = parseVersions(await $.fs.read(`${root}/${project}`).catch(() => ''))
143 if (!version) return null
144
145 const since = await findSince($, root, project, version)
146 const range = since ? `${since}..HEAD` : 'HEAD'
147 const subjects = (await git($, root, 'log', '--no-merges', '-n', String(MAX_COMMITS), '--format=%s', '--end-of-options', range))?.split('\n').filter(Boolean) ?? []
148 const commits = Number(await git($, root, 'rev-list', '--count', '--no-merges', '--end-of-options', range)) || subjects.length
149 const { target, isFastlane } = await findTarget($, root, project)
150 const seen = (await $.store.get(`repo:${root}`)) as Seen | undefined
151 const isDrafted = isFastlane
152 ? seen?.draftedFor === version
153 : hasSection(await $.fs.read(`${root}/${target}`).catch(() => ''), version)
154
155 return { root, subjects, release: { version, build, since, commits, target, isDrafted } }
156}
157
158/** Drafts the notes with Haiku and writes them to the target; resolves the notes, or why it wrote nothing. */
159async function draft($: EngineInterface, { root, subjects, release }: Scan): Promise<{ notes: string } | { error: string }> {
160 const path = `${root}/${release.target}`
161 if (!(await isInside($, root, path))) return { error: `${release.target} leads outside the repository, so it was left alone.` }
162
163 let notes = FALLBACK
164 if (subjects.length) {
165 const reply = await $.model.complete({
166 model: 'haiku',
167 prompt: PROMPT.replace('{version}', release.version).replace('{commits}', subjects.map(s => `- ${s}`).join('\n')),
168 maxTokens: 1200,
169 timeoutMs: 30_000,
170 })
171 notes = reply.isAnswered ? cleanNotes(reply.text) : ''
172 if (!notes) return { error: 'the model gave no answer.' }
173 }
174
175 if (release.target.endsWith('release_notes.txt')) await $.fs.write(path, `${notes}\n`)
176 else await $.fs.write(path, withSection(await $.fs.read(path).catch(() => ''), release.version, notes))
177 const seen = ((await $.store.get(`repo:${root}`)) ?? {}) as Seen
178 await $.store.set(`repo:${root}`, { ...seen, version: release.version, draftedFor: release.version })
179 await update($, current, () => ({ ...release, isDrafted: true }))
180
181 return { notes }
182}
183
184// Skips a check while one runs, so a turn ending mid-draft does not draft twice.
185let isChecking = false
186
187/** Rescans, and drafts on its own once the version has changed since this repository was last seen. */
188async function check($: EngineInterface) {
189 if (isChecking) return
190 isChecking = true
191 try {
192 const found = await scan($)
193 await update($, current, () => found?.release ?? null)
194 if (!found) return
195
196 const key = `repo:${found.root}`
197 const seen = (await $.store.get(key)) as Seen | undefined
198 // The first sight of a repository is its baseline, not a bump.
199 if (seen?.version === found.release.version) return
200 await $.store.set(key, { ...seen, version: found.release.version })
201 if (!seen || found.release.isDrafted) return
202
203 const drafted = await draft($, found)
204 $.ui.toast(
205 'notes' in drafted
206 ? `Release notes for ${found.release.version} drafted in ${found.release.target}`
207 : `Release notes for ${found.release.version} not drafted: ${drafted.error}`,
208 { timeoutMs: 8000 },
209 )
210 } finally {
211 isChecking = false
212 }
213}
214
215export const register: Register = on => {
216 on('session.start', async ($, e, next) => {
217 const result = await next(e)
218 await $.command.register({ name: 'draft-release-notes', description: 'Draft App Store release notes for the current version from the commits since the last release' })
219 // On a timer, so neither the session's start nor a turn's end waits on the model.
220 $.clock.after(0, () => check($))
221
222 return result
223 })
224
225 // A turn may have bumped the version, by hand or through fastlane.
226 on('turn.complete', async ($, e, next) => {
227 const result = await next(e)
228 if (e.agentId === undefined) $.clock.after(0, () => check($))
229
230 return result
231 })
232
233 on('command.run', { command: 'draft-release-notes' }, async $ => {
234 const found = await scan($)
235 await update($, current, () => found?.release ?? null)
236 if (!found) return { text: 'No Xcode project with a MARKETING_VERSION in this repository.' }
237 const drafted = await draft($, found)
238 if (!('notes' in drafted)) return { text: `Could not draft the release notes: ${drafted.error}` }
239
240 return { text: `Release notes for ${found.release.version} written to ${found.release.target}:\n\n${drafted.notes}` }
241 })
242
243 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244 const release = await read($, current)
245 if (e.props.hasSurvey || !release) return next(e)
246
247 const { Box, Text } = $.ui.resolve(e)
248 // Stack above whatever the plugins beneath draw, so other bands stay.
249 const below = await next(e)
250
251 return (
252 <Box flexDirection="column">
253 <Box flexDirection="row" paddingX={1} gap={2}>
254 <Text dimColor>📝 v{release.version}{release.build ? ` (${release.build})` : ''}</Text>
255 <Text dimColor>{release.commits} commits since {release.since ?? 'the start'}</Text>
256 {release.isDrafted ? <Text color="green">notes drafted</Text> : <Text color="gray">notes not drafted · /draft-release-notes</Text>}
257 </Box>
258 {below}
259 </Box>
260 )
261 })
262}
263types/index.d.ts 22 lines1/** Where the Xcode project in the working directory stands, as the last scan found it. */
2export type Release = {
3 /** `MARKETING_VERSION`, as in 2.4.0. */
4 version: string
5 /** `CURRENT_PROJECT_VERSION`; null when the project sets none. */
6 build: string | null
7 /** The tag, or short commit, the notes count from; null when the whole history counts. */
8 since: string | null
9 /** Commits since then, merges left out. */
10 commits: number
11 /** The file the notes go to, relative to the repository root. */
12 target: string
13 /** True once that file holds notes for this version. */
14 isDrafted: boolean
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'release-notes': { release: Release | null }
20 }
21}
22