SLOPSHOPPER

Resource Watch

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…

bandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
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 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 |


⏸ 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. 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>

⏱ 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. 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>

🛫 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>

📝 release-notes

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>

📱 resource-watch

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.

  • Simulators: in an Xcode project, or once the session has used one, every booted simulator with the sessions holding it. A session takes a simulator when 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: a project, on any claude-design tool call on it.
  • Xcode app: on Xcode MCP calls that change its shared state: switching scheme, run destination or test plan, opening or closing a workspace, building, running, testing or stopping.

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>

🗜 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. Once the cache is cold it does not compact sooner, since the summary would re-read the whole conversation uncached.
  • 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. 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>

⛅ 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 424 lines
1import { 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}
424
types/index.d.ts 30 lines
1/** 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