Shows which Claude Code session is using each booted Apple simulator, Claude Design project and the Xcode app, above the prompt, and when another session…

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 424 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Claim, Item, Kind, Sim, View } from '../types'
5
6const POLL_MS = 30_000
7// A session rewrites its claim every poll, so one older than this belongs to a session that has gone.
8const STALE_MS = 2 * 60_000
9
10// Xcode MCP tools that run on the scheme's run destination without naming it.
11const XCODE_RUN = /^mcp__xcode__(RunProject|RunAllTests|RunSomeTests|DeviceInteraction\w+)$/
12// Xcode MCP tools that drive the one Xcode app every session shares: its scheme, destination, workspace, builds and runs.
13const XCODE_STATE = /^mcp__xcode__(XcodeSwitch\w+|XcodeOpenWorkspace|XcodeCloseWorkspace|BuildProject|RunProject|StopProject|RunAllTests|RunSomeTests)$/
14const DESIGN = /^mcp__claude-design__/
15const SIM_COMMAND = /\b(simctl|xcodebuild|Simulator)\b/
16// simctl subcommands and xcodebuild flags that only look: listing devices, SDKs or destinations, help and versions.
17const SIMCTL_READ = /^(list|help|getenv|-h|--help|--version)$/
18const XCODEBUILD_READ = /^-(list|showsdks|showdestinations|showBuildSettings|version|help)$/
19// Xcode MCP tools after which this session is done with the simulators it ran on.
20const XCODE_DONE = /^mcp__xcode__(StopProject|DeviceInteractionEndSession)$/
21// How long a refused session counts as waiting for a simulator, so a holder that is done much later still tells it.
22const WAIT_MS = 60 * 60_000
23
24const view = atom({ plugin: 'resource-watch', key: 'view' } as const, null as View | null)
25const mine = atom({ plugin: 'resource-watch', key: 'mine' } as const, null as Claim | null)
26
27/** Drops control characters, so a device or project name cannot carry terminal escapes. */
28export const clean = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
29
30type SimctlDevice = { udid: string; name: string; state: string }
31
32/** Whether every simctl and xcodebuild call in a command only looks, as `simctl list` does, so it takes no simulator. */
33export function isReadOnly(command: string) {
34 const simctl = [...command.matchAll(/\bsimctl\s+(\S+)/g)].map(m => m[1]!)
35 const xcodebuild = [...command.matchAll(/\bxcodebuild\b([^|;&\n]*)/g)].map(m => m[1]!.trim().split(/\s+/))
36 // `open -a Simulator` and the like.
37 if (!simctl.length && !xcodebuild.length) return false
38
39 return simctl.every(s => SIMCTL_READ.test(s)) && xcodebuild.every(args => args.some(a => XCODEBUILD_READ.test(a)))
40}
41
42/** The booted devices in `xcrun simctl list devices booted -j`, with each runtime key read as `iOS 27.2`. */
43export function parseBooted(json: { devices?: Record<string, SimctlDevice[]> }): Sim[] {
44 return Object.entries(json.devices ?? {}).flatMap(([key, devices]) => {
45 const runtime = (key.split('.').pop() ?? key).replace(/-(\d+)-(\d+)$/, ' $1.$2').replace(/-(\d+)$/, ' $1')
46
47 return devices.filter(d => d.state === 'Booted').map(d => ({ udid: d.udid, name: clean(d.name), runtime }))
48 })
49}
50
51/**
52 * The booted simulators a tool call works on: those it names by UDID or by name (the longest name
53 * wins, so `iPhone 18 Pro Max` is not also `iPhone 18 Pro`), else the only booted one when the call
54 * says `booted` or is an Xcode run that uses the scheme's destination.
55 */
56export function simulatorsIn(tool: string, input: Record<string, unknown>, booted: readonly Sim[]): Sim[] {
57 const text = tool === 'Bash' ? String(input.command ?? '') : tool.startsWith('mcp__xcode__') ? JSON.stringify(input) : ''
58 if (!text || (tool === 'Bash' && (!SIM_COMMAND.test(text) || isReadOnly(text)))) return []
59
60 const byUdid = booted.filter(s => text.includes(s.udid))
61 if (byUdid.length) return byUdid
62 const byName = booted.filter(s => text.includes(s.name))
63 const named = byName.filter(s => !byName.some(o => o.name !== s.name && o.name.includes(s.name) && text.includes(o.name)))
64 if (named.length) return named
65
66 return booted.length === 1 && (/\bbooted\b/.test(text) || XCODE_RUN.test(tool)) ? [...booted] : []
67}
68
69export type Found = { id: string; name: string }
70type Context = { tool: string; input: Record<string, unknown>; booted: readonly Sim[] }
71
72/** What each kind of shared thing a tool call uses. Simulators and Claude Design projects are the baseline. */
73export const DETECTORS: { kind: Kind; detect: (c: Context) => Found[] }[] = [
74 { kind: 'simulators', detect: c => simulatorsIn(c.tool, c.input, c.booted).map(s => ({ id: s.udid, name: s.name })) },
75 { kind: 'designs', detect: c => (DESIGN.test(c.tool) && typeof c.input.project_id === 'string' ? [{ id: c.input.project_id, name: '' }] : []) },
76 { kind: 'xcode', detect: c => (XCODE_STATE.test(c.tool) ? [{ id: 'Xcode', name: 'the Xcode app' }] : []) },
77]
78
79const ICON: Record<Kind, string> = { simulators: '📱', designs: '🎨', xcode: '🛠' }
80const ALSO: Record<Kind, string> = {
81 simulators: 'which you are also using',
82 designs: 'which you are also working on',
83 xcode: 'which you are also driving',
84}
85
86/** How an item reads in the band, a toast and a message. */
87export const label = (kind: Kind, id: string, item?: Item) =>
88 kind === 'designs' ? item?.name || `Claude Design project ${id.slice(0, 8)}` : (item?.name ?? id)
89
90/** Whether the item was used within `holdMs`, by a session still writing its claim. */
91const isHeld = (claim: Claim, at: number | undefined, now: number, holdMs: number) =>
92 at !== undefined && now - claim.at < STALE_MS && now - at < holdMs
93
94/** How a session is named to others: `the <project> session`, or `another <project> session` beside one of the same project. */
95const who = (other: Claim, me: Claim | null) => (other.project === me?.project ? `another ${other.project} session` : `the ${other.project} session`)
96
97/** Labels the sessions holding an item, this one first. */
98export function holders(me: Claim | null, others: readonly Claim[], kind: Kind, id: string, now: number, holdMs: number) {
99 const labels = me && isHeld({ ...me, at: now }, me.items[kind]?.[id]?.at, now, holdMs) ? ['this session'] : []
100 for (const other of others) {
101 if (isHeld(other, other.items[kind]?.[id]?.at, now, holdMs)) labels.push(who(other, me))
102 }
103
104 return labels
105}
106
107export type Row = { icon: string; label: string; status: string; color: string }
108
109/** The band's rows: every booted simulator in an Xcode project or once this session used one, then the Claude Design projects and Xcode app this session holds. */
110export function rows(v: View, me: Claim | null, now: number, holdMs: number): Row[] {
111 const out: Row[] = []
112 const held = (kind: Kind) => Object.entries(me?.items[kind] ?? {}).filter(([, item]) => now - item.at < holdMs)
113 if (v.isXcode || held('simulators').length) {
114 if (!v.booted.length) out.push({ icon: ICON.simulators, label: 'no simulator booted', status: '', color: 'gray' })
115 for (const sim of v.booted) {
116 out.push({ icon: ICON.simulators, label: `${sim.name} (${sim.runtime})`, ...status(holders(me, v.others, 'simulators', sim.udid, now, holdMs)) })
117 }
118 }
119 for (const kind of ['designs', 'xcode'] as const) {
120 for (const [id, item] of held(kind)) {
121 out.push({ icon: ICON[kind], label: label(kind, id, item), ...status(holders(me, v.others, kind, id, now, holdMs)) })
122 }
123 }
124
125 return out
126}
127
128function status(who: string[]) {
129 if (!who.length) return { status: 'free', color: 'gray' }
130 const isMine = who[0] === 'this session'
131 if (isMine && who.length > 1) return { status: `⚠ also ${who.slice(1).join(', ')}`, color: 'yellow' }
132
133 return { status: isMine ? 'this session' : who.join(', '), color: isMine ? 'green' : 'cyan' }
134}
135
136/** The message the holding session's model reads when this session picks up a Claude Design project or the Xcode app it holds. */
137export const holderNote = (me: Claim, kind: Kind, name: string) =>
138 `resource-watch: the ${me.project} session (${me.session}) has started using ${name}, ${ALSO[kind]}. ` +
139 `If you are in the middle of something on it, reply to this message to ask it to wait; otherwise no reply is needed.`
140
141/** The note this session's model reads when another session also holds the Claude Design project or Xcode app it just used. */
142export const selfNote = (name: string, holders: string[]) =>
143 `resource-watch: ${name} is also in use by ${holders.join(' and ')}, which has been told you are using it. ` +
144 `If a reply asks you to wait, wait for its next message.`
145
146/** The message the holding session's model reads when this session wants a simulator it holds. */
147export const askNote = (me: Claim, name: string) =>
148 `resource-watch: the ${me.project} session (${me.session}) wants to use ${name}, which you are using, and is waiting for your reply. ` +
149 `Reply to this message to say whether it can go ahead now or should wait; resource-watch tells it when you are done.`
150
151/** The refusal this session's model reads in place of a call on a simulator another session holds. */
152export const waitNote = (name: string, holders: string[]) =>
153 `resource-watch: ${name} is in use by ${holders.join(' and ')}, which has been asked whether you can use it. ` +
154 `Wait for its reply before calling again: if it says go ahead, repeat the call and it will run; if it asks you to wait, wait for its next message or the note that it is done. ` +
155 `Do not switch to another simulator or create one; ask your user if you cannot wait.`
156
157/** The message a waiting session's model reads once the holder is done with its simulators. */
158export const doneNote = (me: Claim, names: string[]) =>
159 `resource-watch: the ${me.project} session (${me.session}) is done with ${names.join(', ')}, so you can use it now.`
160
161// The folder every session's claim sits in, and how long a claim holds, set when the session starts.
162let dir = ''
163let holdMs = 10 * 60_000
164// Clashes already told, `<kind>:<item>:<session>`, so each is told once.
165const told = new Set<string>()
166// Simulator clashes already asked about, `<udid>:<session>`: the next call goes through, as the holder has had its say.
167const asked = new Set<string>()
168// The simulators this session held at the last poll, so one whose hold ran out is noticed.
169let heldSims = new Set<string>()
170
171async function bootedSims($: EngineInterface) {
172 const ran = await $.process.run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'], { timeoutMs: 15_000 }).catch(() => null)
173 if (!ran || ran.exitCode !== 0) return []
174 try {
175 return parseBooted(JSON.parse(ran.stdout))
176 } catch {
177 return []
178 }
179}
180
181async function others($: EngineInterface, session: string) {
182 const entries = await $.fs.list(dir).catch(() => [])
183 const claims: Claim[] = []
184 for (const entry of entries) {
185 if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === `${session}.json`) continue
186 try {
187 const claim = JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) as Claim
188 claims.push({ ...claim, project: clean(claim.project), items: claim.items ?? {} })
189 } catch {
190 // A claim mid-write or malformed is read on the next poll.
191 }
192 }
193
194 return claims
195}
196
197async function save($: EngineInterface, claim: Claim) {
198 await $.fs.write(`${dir}/${claim.session}.json`, JSON.stringify(claim)).catch(() => undefined)
199}
200
201/** Records this session's use of the items. */
202async function record($: EngineInterface, kind: Kind, found: Found[]) {
203 const me = await read($, mine)
204 if (!me) return null
205 const now = await $.clock.now()
206 const kept = { ...me.items[kind] }
207 for (const { id, name } of found) kept[id] = { name: name || (me.items[kind]?.[id]?.name ?? ''), at: now }
208 // A simulator this session now uses is no longer one it waits for.
209 const waiting = kind === 'simulators' ? Object.fromEntries(Object.entries(me.waiting ?? {}).filter(([id]) => !found.some(f => f.id === id))) : me.waiting
210 const next: Claim = { ...me, at: now, items: { ...me.items, [kind]: kept }, waiting }
211 await update($, mine, () => next)
212 await save($, next)
213
214 return next
215}
216
217/**
218 * Refuses a call on simulators another live session holds, once per pair, after asking that session
219 * and noting that this one waits; undefined lets the call run.
220 */
221async function gate($: EngineInterface, sims: Sim[]) {
222 const me = await read($, mine)
223 if (!me || !sims.length) return undefined
224 const now = await $.clock.now()
225 const claims = await others($, me.session)
226 const waiting = { ...me.waiting }
227 const refusals: string[] = []
228 for (const sim of sims) {
229 const holding = claims.filter(c => isHeld(c, c.items.simulators?.[sim.udid]?.at, now, holdMs) && !asked.has(`${sim.udid}:${c.session}`))
230 if (!holding.length) continue
231 for (const other of holding) {
232 asked.add(`${sim.udid}:${other.session}`)
233 $.ui.toast(`${sim.name} is in use by ${who(other, me)}; asked it, waiting`, { timeoutMs: 8000 })
234 await $.session.send({ to: { sessionId: other.session }, text: askNote(me, sim.name) }).catch(() => undefined)
235 }
236 waiting[sim.udid] = now
237 refusals.push(waitNote(sim.name, holding.map(c => who(c, me))))
238 }
239 if (!refusals.length) return undefined
240 const next: Claim = { ...me, at: now, waiting }
241 await update($, mine, () => next)
242 await save($, next)
243
244 return refusals.join('\n')
245}
246
247/** Tells every live session waiting for one of these simulators that this session is done with it. */
248async function tellWaiters($: EngineInterface, me: Claim, ids: string[]) {
249 if (!ids.length) return
250 const now = await $.clock.now()
251 for (const other of await others($, me.session)) {
252 const names = ids.filter(id => isHeld(other, other.waiting?.[id], now, WAIT_MS)).map(id => me.items.simulators?.[id]?.name ?? id)
253 if (names.length) await $.session.send({ to: { sessionId: other.session }, text: doneNote(me, names) }).catch(() => undefined)
254 }
255}
256
257/** Lets go of this session's simulators, as after `StopProject`, and tells the sessions waiting for them. */
258async function done($: EngineInterface) {
259 const me = await read($, mine)
260 const ids = Object.keys(me?.items.simulators ?? {})
261 if (!me || !ids.length) return
262 const next: Claim = { ...me, at: await $.clock.now(), items: { ...me.items, simulators: {} } }
263 await update($, mine, () => next)
264 await save($, next)
265 heldSims = new Set()
266 await tellWaiters($, me, ids)
267}
268
269/**
270 * For each Claude Design project or Xcode app another live session also holds, once per pair: toasts,
271 * messages that session, and tells this session's model who holds it.
272 */
273async function warn($: EngineInterface, kind: Kind, found: Found[]) {
274 const me = await read($, mine)
275 if (!me || !found.length) return
276 const now = await $.clock.now()
277 const claims = await others($, me.session)
278 const v = await read($, view)
279 await update($, view, () => ({ isXcode: v?.isXcode ?? false, booted: v?.booted ?? [], others: claims }))
280
281 for (const { id } of found) {
282 const name = label(kind, id, me.items[kind]?.[id])
283 const fresh = claims.filter(c => isHeld(c, c.items[kind]?.[id]?.at, now, holdMs) && !told.has(`${kind}:${id}:${c.session}`))
284 if (!fresh.length) continue
285 for (const other of fresh) {
286 told.add(`${kind}:${id}:${other.session}`)
287 $.ui.toast(`${name} is also in use by ${who(other, me)}`, { timeoutMs: 8000 })
288 await $.session.send({ to: { sessionId: other.session }, text: holderNote(me, kind, name) }).catch(() => undefined)
289 }
290 const text = selfNote(name, fresh.map(c => who(c, me)))
291 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }).catch(() => undefined)
292 }
293}
294
295async function refresh($: EngineInterface) {
296 const me = await read($, mine)
297 if (!me || !dir) return
298 const now = await $.clock.now()
299 await update($, mine, () => ({ ...me, at: now }))
300 await save($, { ...me, at: now })
301 const was = await read($, view)
302 const isXcode = was?.isXcode ?? false
303 const hasSims = Object.values(me.items.simulators ?? {}).some(s => now - s.at < holdMs)
304 const [sims, claims] = await Promise.all([isXcode || hasSims ? bootedSims($) : Promise.resolve([]), others($, me.session)])
305 await update($, view, () => ({ isXcode, booted: sims, others: claims }))
306
307 // A hold that ran out since the last poll is this session being done with that simulator.
308 const held = new Set(Object.entries(me.items.simulators ?? {}).filter(([, s]) => now - s.at < holdMs).map(([id]) => id))
309 const lapsed = [...heldSims].filter(id => !held.has(id))
310 heldSims = held
311 await tellWaiters($, me, lapsed)
312 // Once a holder lets a simulator go, a later clash with it is asked about again.
313 for (const key of asked) {
314 const [udid, session] = key.split(':') as [string, string]
315 if (!claims.some(c => c.session === session && isHeld(c, c.items.simulators?.[udid]?.at, now, holdMs))) asked.delete(key)
316 }
317}
318
319/** Tells the sessions waiting for this one's simulators, and every live session sharing its other items, that it has let them go. */
320async function release($: EngineInterface, me: Claim) {
321 const now = await $.clock.now()
322 await tellWaiters($, me, Object.entries(me.items.simulators ?? {}).filter(([, s]) => now - s.at < holdMs).map(([id]) => id))
323 for (const other of await others($, me.session)) {
324 const shared = Object.entries(me.items).filter(([kind]) => kind !== 'simulators').flatMap(([kind, items]) =>
325 Object.entries(items ?? {})
326 .filter(([id, item]) => now - item.at < holdMs && isHeld(other, other.items[kind as Kind]?.[id]?.at, now, holdMs))
327 .map(([id, item]) => label(kind as Kind, id, item)),
328 )
329 if (!shared.length) continue
330 const text = `resource-watch: the ${me.project} session (${me.session}) has ended and is no longer using ${shared.join(', ')}.`
331 await $.session.send({ to: { sessionId: other.session }, text }).catch(() => undefined)
332 }
333}
334
335export const register: Register = (on, options) => {
336 holdMs = (typeof options.hold === 'number' && options.hold > 0 ? options.hold : 10) * 60_000
337
338 on('session.start', async ($, e, next) => {
339 const result = await next(e)
340 const home = await $.process.run(['printenv', 'HOME']).catch(() => null)
341 if (!home?.stdout.trim()) return result
342 dir = `${home.stdout.trim()}/.claude/resource-watch`
343 const root = await $.session.root()
344 const session = (await $.session.id()).replace(/[^\w-]/g, '')
345 const kept = await read($, mine)
346 if (kept?.session !== session) {
347 await update($, mine, () => ({ session, project: root.split('/').pop() || root, at: 0, items: {} }))
348 }
349
350 const tracked = await $.process.run(['git', '-C', root, 'ls-files', '*.xcodeproj/project.pbxproj', '*.xcworkspace/contents.xcworkspacedata'], { timeoutMs: 15_000 }).catch(() => null)
351 const listed = tracked?.exitCode === 0 ? [] : await $.fs.list(root).catch(() => [])
352 const isXcode = !!tracked?.stdout.trim() || listed.some(f => /\.(xcodeproj|xcworkspace)$/.test(f.name))
353 await update($, view, () => ({ isXcode, booted: [], others: [] }))
354 await refresh($)
355 $.clock.every(POLL_MS, () => void refresh($))
356
357 return result
358 })
359
360 on('session.end', async ($, e, next) => {
361 const me = await read($, mine)
362 if (me && dir) {
363 await release($, me)
364 await save($, { ...me, at: 0, items: {} })
365 }
366
367 return next(e)
368 })
369
370 on('tool.call', async ($, e, next) => {
371 const input = e as unknown as Record<string, unknown>
372 const command = e.tool === 'Bash' ? String(input.command ?? '') : ''
373 const touchesSims = SIM_COMMAND.test(command) || e.tool.startsWith('mcp__xcode__')
374
375 // Before the call, so a simulator another session holds waits for that session's answer.
376 const refusal = touchesSims ? await gate($, simulatorsIn(e.tool, input, await bootedSims($))) : undefined
377 if (refusal) return { deny: refusal }
378
379 const result = await next(e)
380 if (result.deny !== undefined) return result
381
382 // After the call, so a simulator it just booted is in the list.
383 const booted = touchesSims ? await bootedSims($) : []
384 for (const { kind, detect } of DETECTORS) {
385 const found = detect({ tool: e.tool, input, booted })
386 if (found.length && (await record($, kind, found)) && kind !== 'simulators') await warn($, kind, found)
387 }
388 if (XCODE_DONE.test(e.tool) && !result.isError) await done($)
389 if (e.tool === 'mcp__claude-design__create_project' && typeof input.name === 'string') {
390 const id = /"(?:project_)?id"\s*:\s*"([^"]+)"/.exec(JSON.stringify(result.result ?? ''))?.[1]
391 if (id) await record($, 'designs', [{ id, name: clean(input.name) }])
392 }
393
394 return result
395 }).catch(($, e, next) => next(e))
396
397 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
398 const v = await read($, view)
399 const me = await read($, mine)
400 const list = v && !e.props.hasSurvey ? rows(v, me, await $.clock.now(), holdMs) : []
401
402 if (!list.length) {
403 return next(e)
404 }
405
406 const { Box, Text } = $.ui.resolve(e)
407 // Stack above whatever the plugins beneath draw, so other bands stay.
408 const below = await next(e)
409
410 return (
411 <Box flexDirection="column">
412 <Box flexDirection="row" flexWrap="wrap" paddingX={1} columnGap={2}>
413 {list.map(row => (
414 <Text key={row.icon + row.label} color={row.color} wrap="truncate-end">
415 {row.icon} {row.label}{row.status ? ` · ${row.status}` : ''}
416 </Text>
417 ))}
418 </Box>
419 {below}
420 </Box>
421 )
422 })
423}
424types/index.d.ts 30 lines1/** A booted simulator, as `xcrun simctl list devices booted -j` reports it. */
2export type Sim = { udid: string; name: string; runtime: string }
3
4/** The shared things a session can hold. Simulators and Claude Design projects are the baseline; the Xcode app sits on top. */
5export type Kind = 'simulators' | 'designs' | 'xcode'
6
7/** One held item, stamped with when it was last used. */
8export type Item = { name: string; at: number }
9
10/** One session's hold on shared things. */
11export type Claim = {
12 session: string
13 /** The basename of the session's root, the label other sessions show. */
14 project: string
15 /** When the session last wrote its claim; a claim not rewritten for a while belongs to a session that is gone. */
16 at: number
17 items: Partial<Record<Kind, Record<string, Item>>>
18 /** The simulators, by UDID, this session was refused because another session held them, and when; their holders tell it once they are done. */
19 waiting?: Record<string, number>
20}
21
22/** What the band draws from: the booted simulators and every other live session's claim. */
23export type View = { isXcode: boolean; booted: Sim[]; others: Claim[] }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'resource-watch': { view: View | null; mine: Claim | null }
28 }
29}
30