Mirrors ios-dock and swift-design-lint state into a file Holdout.app shows on the Touch Bar

Your coding agents, iOS Simulator, Xcode builds and Mac health, on the Touch Bar.
Holdout adds a ✋ to the Control Strip. Tap it and a strip opens beside the Control Strip with four tabs:
The Control Strip icon flashes green when a session finishes, a hammer for builds, amber when an agent needs you, and red when memory is critical.
git clone https://github.com/egemengunel/Holdout.git
cd Holdout
xcodebuild -project Holdout.xcodeproj -scheme Holdout -configuration Release build
Copy the built Holdout.app to /Applications and open it (Product ▸ Archive ▸ Copy App also works). Then open Settings… from the menu bar icon and Connect the agents you use. Restart them afterwards.
Holdout uses Apple's private Touch Bar APIs (DFRFoundation and NSTouchBar system-modal presentation), looked up at runtime so a future macOS that removes them fails soft. That also means it can't ship on the Mac App Store.
Agents report through a small hook script, Hooks/holdout-hook.sh, which writes one JSON file per session to ~/Library/Application Support/Holdout/. Nothing leaves your Mac.
See CLAUDE.md for the architecture.
MIT
hooks/register.ts 122 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { IosProject, RepoInfo } from '../types'
4
5// Mirrors other mods' state into ~/Library/Application Support/Holdout/feeds/<session>.json,
6// which Holdout.app watches to fill its Project tab. It only reads those mods' state.
7//
8// The other way, Holdout's Touch Bar buttons write commands/<session>.json; this picks a
9// command up once and submits the same prompts ios-dock's buttons do.
10
11const REPO = { plugin: 'ios-dock', key: 'repo' } as const
12const PROJECT = { plugin: 'ios-dock', key: 'project' } as const
13const BUILD = { plugin: 'ios-dock', key: 'build' } as const
14const SHIP = { plugin: 'ios-dock', key: 'ship' } as const
15const LINT = { plugin: 'swift-design-lint', key: 'tally' } as const
16
17const INTERVAL_MS = 1_000
18
19/** Kept in step with ios-dock's ACTIONS. */
20const PROMPTS: Record<string, (project: IosProject | null, repo: RepoInfo | null) => string[] | null> = {
21 build: p => p && [
22 `holdout: the user pressed Build on the Touch Bar for ${p.name}.`,
23 `Build the Xcode project in ${p.root} through the xcode MCP (XcodeOpenWorkspace, BuildProject, then GetBuildLog with severity "warning").`,
24 'If it fails, fix the errors and build again until it is clean. Report the warnings at the end.',
25 ],
26 lint: p => p && [
27 `holdout: the user pressed Design lint & fix on the Touch Bar for ${p.name}.`,
28 `In ${p.root}, list the Swift files changed on this branch against the default branch, plus uncommitted ones.`,
29 "Check each against the project's CLAUDE.md design-system and architecture rules (typography, icons, colors, buttons, MV vs MVVM and so on).",
30 'Fix the violations, touching only the offending lines, then build once to confirm it compiles. Summarize what you changed and anything you left on purpose.',
31 ],
32 commit: (_, r) => r && [
33 `holdout: the user pressed Review & commit on the Touch Bar for ${r.name}.`,
34 `In ${r.root}, review the uncommitted changes for bugs and leftovers, then propose an atomic commit grouping with messages.`,
35 'Do not commit yet: wait for the user to approve the grouping in their next message.',
36 ],
37}
38
39let stop: (() => void) | undefined
40let lastWritten = ''
41/** null until the first sample, whose build (if any) predates this load and has no known age. */
42let lastBuild: string | null = null
43/** When ios-dock's build result last changed, so Holdout can tell it from a newer Xcode build. */
44let buildAt: number | null = null
45let lastCommand = ''
46
47async function folder($: EngineInterface): Promise<{ base: string; session: string } | null> {
48 const [home, session] = await Promise.all([$.env.get('HOME'), $.session.id()])
49 return home ? { base: `${home}/Library/Application Support/Holdout`, session } : null
50}
51
52async function publish($: EngineInterface) {
53 const [repo, project, build, ship, lint] = await Promise.all([
54 $.state.get(REPO), $.state.get(PROJECT), $.state.get(BUILD), $.state.get(SHIP), $.state.get(LINT),
55 ])
56 const feed = {
57 repo: repo.value ?? null,
58 project: project.value ?? null,
59 build: build.value ?? null,
60 ship: ship.value ?? null,
61 lint: lint.value ?? null,
62 // What this plugin offers; Holdout shows whatever is listed here.
63 actions: [
64 ...(project.value ? [{ id: 'build', title: 'Build', symbol: 'hammer' }, { id: 'lint', title: 'Lint', symbol: 'wand.and.stars' }] : []),
65 ...(repo.value ? [{ id: 'commit', title: 'Commit', symbol: 'checkmark.circle' }] : []),
66 ],
67 badges: lint.value && lint.value.checkedEdits > 0 && lint.value.issues > 0
68 ? [{ symbol: 'pencil.line', title: String(lint.value.issues), detail: lint.value.issues === 1 ? 'lint issue' : 'lint issues', tint: 'orange' }]
69 : [],
70 }
71 const body = JSON.stringify(feed)
72 if (body === lastWritten) return
73
74 const [where, cwd, now] = await Promise.all([folder($), $.session.cwd(), $.clock.now()])
75 if (!where) return
76 lastWritten = body
77 const buildJSON = JSON.stringify(feed.build)
78 if (lastBuild === null) {
79 lastBuild = buildJSON
80 } else if (buildJSON !== lastBuild) {
81 lastBuild = buildJSON
82 buildAt = feed.build ? Math.floor(now / 1000) : null
83 }
84 await $.fs.write(
85 `${where.base}/feeds/${where.session}.json`,
86 JSON.stringify({ session: where.session, cwd, updatedAt: Math.floor(now / 1000), buildAt, ...feed }),
87 )
88}
89
90async function takeCommand($: EngineInterface) {
91 const where = await folder($)
92 if (!where) return
93 const path = `${where.base}/commands/${where.session}.json`
94 const text = await $.fs.read(path).catch(() => '')
95 if (!text.trim()) return
96
97 let command: { id?: string; action?: string }
98 try {
99 command = JSON.parse(text)
100 } catch {
101 return
102 }
103 if (!command.id || command.id === lastCommand) return
104 lastCommand = command.id
105 await $.fs.write(path, '')
106
107 const [repo, project] = await Promise.all([$.state.get(REPO), $.state.get(PROJECT)])
108 const lines = PROMPTS[command.action ?? '']?.(project.value ?? null, repo.value ?? null)
109 if (lines) $.clock.after(1, () => void $.prompt.submit({ text: lines.join('\n\n') }))
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 stop?.()
115 stop = $.clock.every(INTERVAL_MS, () => {
116 void publish($).catch(() => {})
117 void takeCommand($).catch(() => {})
118 })
119 return next(e)
120 })
121}
122types/index.d.ts 40 lines1// The slices of ios-dock's and swift-design-lint's state contracts this bridge reads.
2// Kept in step with ~/.claude/mods/*/types/index.d.ts; the bridge never writes them.
3
4export type IosProject = {
5 name: string
6 root: string
7 architecture: 'MV' | 'MVVM'
8 deploymentTarget: string | null
9 isSynced: boolean
10 branch: string | null
11 hasTestflight: boolean
12}
13
14export type RepoInfo = { name: string; root: string; branch: string | null }
15
16export type IosBuild = {
17 project: string
18 isOk: boolean
19 seconds: number
20 errorCount: number
21 firstError: string | null
22 warningCount: number | null
23}
24
25export type IosShip = {
26 phase: 'idle' | 'confirm' | 'running' | 'shipped' | 'conflict' | 'failed'
27 ahead: number
28 unpushedMain: number
29 note: string | null
30}
31
32export type LintTally = { issues: number; lastFile: string | null; checkedEdits: number }
33
34declare module 'claude-code' {
35 interface PluginState {
36 'ios-dock': { repo: RepoInfo | null; project: IosProject | null; build: IosBuild | null; ship: IosShip }
37 'swift-design-lint': { tally: LintTally }
38 }
39}
40