SLOPSHOPPER

simulator-pane

A pane listing the booted iOS/watchOS/tvOS/visionOS simulators with two-step Shut down buttons, and an optional status line counting them

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · simulator-pane
│ ┃ Simulators ✕ › fix the failing auth test and add an audit log call │ ┃ simctl unavailable │ ┃ [ Refresh ] ⏺ 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 │ │ › /simulators │ ⎿ simulator-pane: simctl unavailable │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Simulators
simctl unavailable [ Refresh ]
README

simulator-pane

A pane listing the booted Apple simulators (iOS, watchOS, tvOS, visionOS) with two-step buttons that shut them down, and an optional status line counting them.

What it does

  • /simulators opens a "Simulators" pane (never opens by itself). It reads xcrun simctl list devices booted -j and shows each booted device as name · runtime · short UDID (for example iPhone 17 · iOS 27.0 · 1A2B3C4D). The runtime name comes from the runtime identifier (…SimRuntime.iOS-27-0 → iOS 27.0, xrOS → visionOS).
  • Each row has a Shut down button, and the pane a Shut down all button. The first press turns the button into Confirm?; the second runs xcrun simctl shutdown <udid> (or xcrun simctl shutdown all), refreshes the pane and shows a toast with the result. Any other press or a refresh resets the confirmation.
  • A Refresh button reloads the list.
  • Status line 📱 N booted while at least one simulator is booted, refreshed at session start and after each main-agent turn (one simctl call, 10 s timeout; subagent turns are skipped). Turn it off with the statusLine option.
  • Without Xcode (or outside macOS) the pane says simctl unavailable and the status line stays empty.

Commands

CommandEffect
/simulatorsReload and open the pane
/simulators refreshReload and reset any pending confirmation
/simulators closeClose the pane

Options

OptionDefaultEffect
statusLinetrueShow 📱 N booted in the status line; false also skips the per-turn simctl call

Install

claude --plugin-dir /path/to/ModsTools/mods/simulator-pane

Limits

  • The list is a snapshot: a simulator booted or shut down elsewhere shows up at the next refresh, turn end or button press.
  • A failed shutdown shows simctl's first error line as is; success is judged by the exit code only.
  • Shut down all stops every booted simulator, including those another tool started.
  • macOS with Xcode (xcrun simctl) only.

Develop

claude plugin validate mods/simulator-pane
claude plugin test mods/simulator-pane   # 14 tests
Source 3 files
hooks/register.tsx 124 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Simulator } from '../types'
5import { ALL, UNAVAILABLE, countLabel, parseBooted, rowLabel, shutdownToast, statusText } from './simulators'
6
7const PANE = 'simulator-pane'
8const TITLE = 'Simulators'
9const LIST_MS = 10_000
10const SHUTDOWN_MS = 60_000
11const rows = atom({ plugin: 'simulator-pane', key: 'rows' } as const, [])
12const error = atom({ plugin: 'simulator-pane', key: 'error' } as const, null)
13const confirm = atom({ plugin: 'simulator-pane', key: 'confirm' } as const, null)
14const notice = atom({ plugin: 'simulator-pane', key: 'notice' } as const, null)
15
16// The booted simulators, or null when simctl cannot be run or answers something else.
17async function listBooted($: EngineInterface): Promise<Simulator[] | null> {
18  try {
19    const ran = await $.process.run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'], { timeoutMs: LIST_MS })
20
21    return ran.exitCode === 0 ? parseBooted(ran.stdout) : null
22  } catch {
23    return null
24  }
25}
26
27// One simctl call: updates the rows and, when enabled, the status line. Leaves a pending confirm alone.
28async function sync($: EngineInterface, showStatus: boolean): Promise<Simulator[] | null> {
29  const list = await listBooted($)
30  await update($, rows, () => list ?? [])
31  await update($, error, () => (list === null ? UNAVAILABLE : null))
32  if (showStatus) $.ui.status(statusText(list === null ? null : list.length))
33
34  return list
35}
36
37// Reloads the list; a refresh always resets a pending confirm.
38async function refresh($: EngineInterface, showStatus: boolean, message: string | null = null): Promise<Simulator[] | null> {
39  const list = await sync($, showStatus)
40  await update($, confirm, () => null)
41  await update($, notice, () => message)
42
43  return list
44}
45
46async function shutdown($: EngineInterface, showStatus: boolean, target: string) {
47  const all = target === ALL
48  const sim = all ? undefined : (await read($, rows)).find(one => one.udid === target)
49  if (!all && sim === undefined) {
50    await refresh($, showStatus)
51    return
52  }
53  let message: string
54  try {
55    const ran = await $.process.run(['xcrun', 'simctl', 'shutdown', all ? 'all' : target], { timeoutMs: SHUTDOWN_MS })
56    message = shutdownToast(sim?.name ?? null, ran.exitCode, ran.stderr)
57  } catch {
58    message = `simctl shutdown failed: ${UNAVAILABLE} or timed out`
59  }
60  await refresh($, showStatus, message)
61  $.ui.toast(message)
62}
63
64async function press($: EngineInterface, showStatus: boolean, target: string) {
65  if ((await read($, confirm)) === target) await shutdown($, showStatus, target)
66  else await update($, confirm, () => target)
67}
68
69export const register: Register = (on, options) => {
70  const showStatus = options?.statusLine !== false
71
72  on('session.start', async ($, e, next) => {
73    await $.command.register({
74      name: 'simulators',
75      description: 'Show the booted simulators in a pane with Shut down buttons (open | refresh | close)',
76    })
77    if (showStatus) await sync($, showStatus)
78
79    return next(e)
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    if (showStatus && e.agentId === undefined) await sync($, showStatus)
84
85    return next(e)
86  })
87
88  on('command.run', { command: 'simulators' }, async ($, e) => {
89    const arg = e.args.trim()
90    if (arg === 'close' || arg === 'off') {
91      await $.ui.close({ id: PANE })
92      return { text: 'Simulators pane closed.' }
93    }
94    const list = await refresh($, showStatus)
95    await $.ui.open({ id: PANE, title: TITLE })
96    if (list === null) return { text: UNAVAILABLE }
97
98    return { text: `Simulators pane ${arg === 'refresh' ? 'refreshed' : 'opened'} (${countLabel(list.length).toLowerCase()}).` }
99  })
100
101  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
102    const { Box, Button, Text } = $.ui.resolve(e)
103    const list: Simulator[] = await read($, rows)
104    const problem = await read($, error)
105    const pending = await read($, confirm)
106    const message = await read($, notice)
107
108    return (
109      <Box flexDirection="column">
110        {problem !== null ? <Text dimColor>{problem}</Text> : <Text bold>{countLabel(list.length)}</Text>}
111        {message !== null && <Text>{message}</Text>}
112        {list.map(sim => (
113          <Box flexDirection="column">
114            <Text>{rowLabel(sim)}</Text>
115            <Button key={`shutdown:${sim.udid}`} label={pending === sim.udid ? 'Confirm?' : 'Shut down'} onPress={() => press($, showStatus, sim.udid)} />
116          </Box>
117        ))}
118        {list.length > 0 && <Button key="shutdown-all" label={pending === ALL ? 'Confirm?' : 'Shut down all'} onPress={() => press($, showStatus, ALL)} />}
119        <Button key="refresh" label="Refresh" onPress={() => refresh($, showStatus)} />
120      </Box>
121    )
122  })
123}
124
hooks/simulators.ts 60 lines
1import type { Simulator } from '../types'
2
3export const UNAVAILABLE = 'simctl unavailable'
4export const ALL = 'all'
5
6const PLATFORM_NAMES: Record<string, string> = { xrOS: 'visionOS' }
7
8// `com.apple.CoreSimulator.SimRuntime.iOS-27-0` → `iOS 27.0`; `xrOS-2-0` → `visionOS 2.0`; anything else is kept as its tail.
9export function runtimeName(identifier: string): string {
10  const tail = identifier.slice(identifier.lastIndexOf('.') + 1)
11  const match = /^(.+?)-(\d+(?:-\d+)*)$/.exec(tail)
12  if (match === null) return tail
13  const platform = match[1]!
14
15  return `${PLATFORM_NAMES[platform] ?? platform} ${match[2]!.replace(/-/g, '.')}`
16}
17
18// The booted devices of `xcrun simctl list devices [booted] -j`, sorted by runtime then name; null when the text is not that JSON.
19export function parseBooted(stdout: string): Simulator[] | null {
20  let json: unknown
21  try {
22    json = JSON.parse(stdout)
23  } catch {
24    return null
25  }
26  const devices = (json as { devices?: unknown } | null)?.devices
27  if (devices === null || typeof devices !== 'object' || Array.isArray(devices)) return null
28  const list: Simulator[] = []
29  for (const [runtime, entries] of Object.entries(devices as Record<string, unknown>)) {
30    if (!Array.isArray(entries)) continue
31    for (const entry of entries as { udid?: unknown; name?: unknown; state?: unknown }[]) {
32      if (entry?.state !== 'Booted' || typeof entry.udid !== 'string' || typeof entry.name !== 'string') continue
33      list.push({ udid: entry.udid, name: entry.name, runtime: runtimeName(runtime) })
34    }
35  }
36
37  return list.sort((a, b) => a.runtime.localeCompare(b.runtime) || a.name.localeCompare(b.name))
38}
39
40export const shortUdid = (udid: string) => udid.slice(0, 8)
41
42export const rowLabel = (sim: Simulator) => `${sim.name} · ${sim.runtime} · ${shortUdid(sim.udid)}`
43
44export const countLabel = (count: number) => (count === 0 ? 'No booted simulators' : `${count} booted simulator${count === 1 ? '' : 's'}`)
45
46// The status line text, or undefined to clear it.
47export const statusText = (count: number | null) => (count === null || count === 0 ? undefined : `📱 ${count} booted`)
48
49export function firstLine(text: string): string {
50  return text.split('\n').map(line => line.trim()).find(line => line !== '') ?? ''
51}
52
53// The toast after a shutdown: `target` is a simulator's name, or null for all.
54export function shutdownToast(target: string | null, exitCode: number, stderr: string): string {
55  if (exitCode === 0) return target === null ? 'Shut down all simulators' : `Shut down ${target}`
56  const reason = firstLine(stderr)
57
58  return `simctl shutdown failed${reason === '' ? ` (exit ${exitCode})` : `: ${reason}`}`
59}
60
types/index.d.ts 13 lines
1export type Simulator = { udid: string; name: string; runtime: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'simulator-pane': {
6      rows: Simulator[]
7      error: string | null
8      confirm: string | null
9      notice: string | null
10    }
11  }
12}
13