SLOPSHOPPER

holdout-bridge

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

newtimer
v0.1.0MITupdated 2026-10-08egemengunel/Holdout/Mods/holdout-bridge
A shopper browsing a rack in a slop shop
README

Holdout

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:

  • Agents: live sessions from Claude Code, Cursor, Codex (CLI and the ChatGPT app), Antigravity CLI and OpenCode. Working, waiting on you, done.
  • Sim: dark mode, Dynamic Type, screenshots, status bar, push, location, boot.
  • Project: Xcode build results, git state, and Build / Commit buttons that prompt the current agent.
  • Mac: memory pressure, swap and CPU, with alerts only when you'd feel it.

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.

Requirements

  • A MacBook Pro with a Touch Bar, macOS 14 or later.
  • Xcode to build it. There is no notarized download yet.

Install

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.

How it works

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.

License

MIT

Source 2 files
hooks/register.ts 122 lines
1import 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}
122
types/index.d.ts 40 lines
1// 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