SLOPSHOPPER

xcode-status

In Xcode projects only: one line with the last build and test result and the booted simulator; flags 0-test runs; blocks a second xcodebuild while one runs

newbandguardpromptprocesstimer
v0.2.0no licenseupdated 2026-10-04narrowstacks/claude-code-mods/xcode-status
A shopper browsing a rack in a slop shop
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane
live-spinnerSpinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet
pr-watchTracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs
house-styleNo em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one
handoff/carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop
agent-jobs/jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes
zsh-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result
agent-modelsPins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on
worktrees/worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once
pr-rulesBlocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules onoffauto` overrides per repo (kept across sessions)

Install

claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods

Or load from a checkout without installing:

claude --plugin-dir ./context-watch --plugin-dir ./live-spinner

Check

claude plugin validate ./context-watch
claude plugin test ./context-watch

tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.

Source 2 files
hooks/register.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BuildResult } from '../types'
5
6const project = atom({ plugin: 'xcode-status', key: 'project' } as const, null as string | null)
7const root = atom({ plugin: 'xcode-status', key: 'root' } as const, null as string | null)
8// Last result per project root, so moving away and back keeps it.
9const results = atom({ plugin: 'xcode-status', key: 'results' } as const, {} as Record<string, BuildResult>)
10const simulator = atom({ plugin: 'xcode-status', key: 'simulator' } as const, null as string | null)
11const tick = atom({ plugin: 'xcode-status', key: 'tick' } as const, 0)
12
13const POLL_MS = 30_000
14const SKIP_DIRS = new Set(['Pods', 'node_modules', 'DerivedData', '.build', 'build', 'Carthage', '.git'])
15const XCODE_CONTAINER = /\.(xcodeproj|xcworkspace)$/
16
17type Entry = { name: string; kind: 'file' | 'dir' | 'other' }
18
19// The project's name when `entries` (the root) or one folder below holds an
20// .xcodeproj or .xcworkspace; null otherwise. A workspace wins over a project.
21export const findXcodeProject = (root: readonly Entry[], children: Record<string, readonly Entry[]> = {}) => {
22  const pick = (entries: readonly Entry[]) => {
23    const found = entries.filter(entry => entry.kind === 'dir' && XCODE_CONTAINER.test(entry.name))
24    const best = found.find(entry => entry.name.endsWith('.xcworkspace')) ?? found[0]
25
26    return best?.name.replace(XCODE_CONTAINER, '') ?? null
27  }
28
29  return pick(root) ?? Object.values(children).map(pick).find(name => name !== null) ?? null
30}
31
32// What a run of xcodebuild (or a script wrapping it) printed: null when the
33// output carries none of xcodebuild's own result markers.
34export const parseResult = (output: string, command: string): Omit<BuildResult, 'at' | 'durationMs'> | null => {
35  const marker = output.match(/\*\* (BUILD|TEST|ANALYZE|ARCHIVE|CLEAN) (SUCCEEDED|FAILED|INTERRUPTED) \*\*/g)?.at(-1)
36  if (marker === undefined) {
37    return null
38  }
39
40  const kind = marker.includes('TEST') ? 'test' : 'build'
41  const ok = marker.includes('SUCCEEDED')
42  const errors = new Set(output.match(/^.*:\d+:\d+: error: .*$/gm) ?? []).size
43  const scheme = command.match(/-scheme\s+("[^"]+"|'[^']+'|\S+)/)?.[1]?.replace(/^["']|["']$/g, '')
44
45  // XCTest: "Executed 42 tests, with 1 failure". Swift Testing: "Test run with 42 tests ... passed/failed ... 3 issues".
46  const xctest = [...output.matchAll(/Executed (\d+) tests?, with (\d+) failures?/g)].at(-1)
47  const swiftTesting = [...output.matchAll(/Test run with (\d+) tests?[^\n]*?(passed|failed)(?:[^\n]*?with (\d+) issues?)?/g)].at(-1)
48  let tests: number | undefined
49  let failures: number | undefined
50  if (xctest !== undefined) {
51    tests = Number(xctest[1])
52    failures = Number(xctest[2])
53  }
54  if (swiftTesting !== undefined) {
55    tests = (tests ?? 0) + Number(swiftTesting[1])
56    failures = (failures ?? 0) + (swiftTesting[2] === 'failed' ? Number(swiftTesting[3] ?? 1) : 0)
57  }
58  if (kind === 'test' && tests === undefined && /Executed 0 tests|Test run with 0 tests/.test(output)) {
59    tests = 0
60  }
61
62  return { kind, ok, errors, scheme, tests, failures }
63}
64
65// "iPhone 16 Pro (iOS 18.2)" for each booted device in `simctl list -j`.
66export const parseBooted = (json: string): string | null => {
67  const data = JSON.parse(json) as { devices: Record<string, { name: string; state: string }[]> }
68  const booted = Object.entries(data.devices).flatMap(([runtime, devices]) =>
69    devices
70      .filter(device => device.state === 'Booted')
71      .map(device => {
72        const os = runtime.match(/SimRuntime\.(\w+)-(\d+)-(\d+)/)
73
74        return os === null ? device.name : `${device.name} (${os[1]} ${os[2]}.${os[3]})`
75      }),
76  )
77
78  return booted.length === 0 ? null : booted.length === 1 ? (booted[0] ?? null) : `${booted[0]} +${booted.length - 1}`
79}
80
81export const ago = (ms: number) => {
82  const minutes = Math.floor(ms / 60_000)
83  if (minutes < 1) return 'just now'
84  if (minutes < 60) return `${minutes}m ago`
85
86  return `${Math.floor(minutes / 60)}h ago`
87}
88
89const detect = async ($: EngineInterface) => {
90  const dir = await $.session.root()
91  const entries = await $.fs.list(dir)
92  const children: Record<string, Entry[]> = {}
93  if (findXcodeProject(entries) === null) {
94    for (const entry of entries) {
95      if (entry.kind === 'dir' && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
96        children[entry.name] = await $.fs.list(`${dir}/${entry.name}`).catch(() => [])
97      }
98    }
99  }
100  const name = findXcodeProject(entries, children)
101  await update($, project, () => name)
102  await update($, root, () => (name === null ? null : dir))
103
104  return name
105}
106
107const pollSimulator = async ($: EngineInterface) => {
108  if ((await read($, project)) === null) {
109    return
110  }
111  const run = await $.process.run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'], { timeoutMs: 10_000 })
112  const booted = run.exitCode === 0 ? parseBooted(run.stdout) : null
113  await update($, simulator, () => booted)
114  await update($, tick, n => n + 1)
115}
116
117const isXcodebuild = (command: string) => /(^|[\s;&|(])xcodebuild\s/.test(command)
118
119export const register: Register = on => {
120  on('session.start', async ($, e, next) => {
121    const started = await next(e)
122    if ((await detect($).catch(() => null)) !== null) {
123      pollSimulator($).catch(() => undefined)
124    }
125    $.clock.every(POLL_MS, () => {
126      pollSimulator($).catch(() => undefined)
127    })
128
129    return started
130  })
131
132  // The root moves with /cd or a worktree switch: look again before each prompt.
133  on('prompt.submit', async ($, e, next) => {
134    const before = await read($, project)
135    const name = await detect($).catch(() => before)
136    if (name !== null && name !== before) {
137      pollSimulator($).catch(() => undefined)
138    }
139
140    return next(e)
141  })
142
143  on('command.run', { command: 'cd' }, async ($, e, next) => {
144    const moved = await next(e)
145    const before = await read($, project)
146    const name = await detect($).catch(() => before)
147    if (name !== null && name !== before) {
148      pollSimulator($).catch(() => undefined)
149    }
150
151    return moved
152  })
153
154  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
155    if ((await read($, project)) === null) {
156      return next(e)
157    }
158
159    // Parallel builds and simulators once froze the Mac for 30+ minutes.
160    if (isXcodebuild(e.command)) {
161      const running = await $.process.run(['pgrep', '-x', 'xcodebuild']).catch(() => undefined)
162      if (running?.exitCode === 0) {
163        return {
164          deny: 'xcode-status: another xcodebuild is already running (a subagent or a background run). Builds and tests must be serialized: wait for it to finish, then run this.',
165        }
166      }
167    }
168
169    const startedAt = await $.clock.now()
170    const ran = await next(e)
171    if (ran.deny !== undefined) {
172      return ran
173    }
174
175    const result = parseResult(ran.text ?? '', e.command)
176    const dir = await read($, root)
177    if (result !== null && dir !== null) {
178      const at = await $.clock.now()
179      await update($, results, all => ({ ...all, [dir]: { ...result, at, durationMs: at - startedAt } }))
180    }
181    if (/\bsimctl\s+(boot|shutdown|erase)\b/.test(e.command)) {
182      await pollSimulator($)
183    }
184
185    return ran
186  })
187
188  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189    const below = await next(e)
190    const name = await read($, project)
191    if (name === null || e.props.hasSurvey) {
192      return below
193    }
194
195    await read($, tick)
196    const dir = await read($, root)
197    const build = dir === null ? null : ((await read($, results))[dir] ?? null)
198    const sim = await read($, simulator)
199    if (build === null && sim === null) {
200      return below
201    }
202
203    const { Box, Text } = $.ui.resolve(e)
204    const now = await $.clock.now()
205    const isZeroTests = build?.kind === 'test' && build.tests === 0
206    const color = build === null ? undefined : !build.ok ? 'red' : isZeroTests ? 'yellow' : 'green'
207    const outcome =
208      build === null
209        ? ''
210        : build.kind === 'test'
211          ? build.tests === undefined
212            ? `tests ${build.ok ? 'passed' : 'failed'}`
213            : isZeroTests
214              ? 'ran 0 tests'
215              : `${build.tests} tests${build.failures ? `, ${build.failures} failed` : ''}`
216          : build.ok
217            ? 'built'
218            : `build failed${build.errors > 0 ? ` (${build.errors} error${build.errors === 1 ? '' : 's'})` : ''}`
219
220    return (
221      <Box flexDirection="column">
222        <Box>
223          <Text dimColor>{build?.scheme ?? name} </Text>
224          {build !== null && <Text color={color}>{build.ok && !isZeroTests ? '✓' : isZeroTests ? '⚠' : '✗'} {outcome}</Text>}
225          {build !== null && <Text dimColor> {ago(now - build.at)}</Text>}
226          {sim !== null && <Text dimColor>{build !== null ? '  ·  ' : ''}◉ {sim}</Text>}
227        </Box>
228        {below}
229      </Box>
230    )
231  })
232}
233
types/index.d.ts 17 lines
1export type BuildResult = {
2  kind: 'build' | 'test'
3  ok: boolean
4  at: number
5  durationMs: number
6  scheme?: string
7  errors: number
8  tests?: number
9  failures?: number
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'xcode-status': { project: string | null; root: string | null; results: Record<string, BuildResult>; simulator: string | null; tick: number }
15  }
16}
17