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

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.
/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).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.📱 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.simctl unavailable and the status line stays empty.| Command | Effect |
|---|---|
/simulators | Reload and open the pane |
/simulators refresh | Reload and reset any pending confirmation |
/simulators close | Close the pane |
| Option | Default | Effect |
|---|---|---|
statusLine | true | Show 📱 N booted in the status line; false also skips the per-turn simctl call |
claude --plugin-dir /path/to/ModsTools/mods/simulator-pane
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.xcrun simctl) only.claude plugin validate mods/simulator-pane
claude plugin test mods/simulator-pane # 14 testshooks/register.tsx 124 lines1import { 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}
124hooks/simulators.ts 60 lines1import 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}
60types/index.d.ts 13 lines1export 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