SLOPSHOPPER

nudge-bridge

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

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

Nudge

Your coding agents, iOS Simulator, Xcode builds and Mac health, on the Touch Bar.

Nudge 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/Nudge.git
cd Nudge
xcodebuild -project Nudge.xcodeproj -scheme Nudge -configuration Release build

Copy the built Nudge.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

Nudge 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/nudge-hook.sh, which writes one JSON file per session to ~/Library/Application Support/Nudge/. Nothing leaves your Mac.

See CLAUDE.md for the architecture.

License

MIT

Source 2 files
hooks/register.ts 160 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/Nudge/feeds/<session>.json,
6// which Nudge.app watches to fill its Project tab. It only reads those mods' state.
7//
8// The other way, Nudge'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//
11// It also lists every mod's slash commands in mods.json, so Nudge can offer any mod's commands
12// as Touch Bar buttons; a button's prompt `/<command>` is run as that command.
13
14const REPO = { plugin: 'ios-dock', key: 'repo' } as const
15const PROJECT = { plugin: 'ios-dock', key: 'project' } as const
16const BUILD = { plugin: 'ios-dock', key: 'build' } as const
17const SHIP = { plugin: 'ios-dock', key: 'ship' } as const
18const LINT = { plugin: 'swift-design-lint', key: 'tally' } as const
19
20const INTERVAL_MS = 1_000
21
22/** Kept in step with ios-dock's ACTIONS. */
23const PROMPTS: Record<string, (project: IosProject | null, repo: RepoInfo | null) => string[] | null> = {
24  build: p => p && [
25    `nudge: the user pressed Build on the Touch Bar for ${p.name}.`,
26    `Build the Xcode project in ${p.root} through the xcode MCP (XcodeOpenWorkspace, BuildProject, then GetBuildLog with severity "warning").`,
27    'If it fails, fix the errors and build again until it is clean. Report the warnings at the end.',
28  ],
29  lint: p => p && [
30    `nudge: the user pressed Design lint & fix on the Touch Bar for ${p.name}.`,
31    `In ${p.root}, list the Swift files changed on this branch against the default branch, plus uncommitted ones.`,
32    "Check each against the project's CLAUDE.md design-system and architecture rules (typography, icons, colors, buttons, MV vs MVVM and so on).",
33    '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.',
34  ],
35  commit: (_, r) => r && [
36    `nudge: the user pressed Review & commit on the Touch Bar for ${r.name}.`,
37    `In ${r.root}, review the uncommitted changes for bugs and leftovers, then propose an atomic commit grouping with messages.`,
38    'Do not commit yet: wait for the user to approve the grouping in their next message.',
39  ],
40}
41
42let timer: { cancel: () => void } | undefined
43let lastWritten = ''
44/** null until the first sample, whose build (if any) predates this load and has no known age. */
45let lastBuild: string | null = null
46/** When ios-dock's build result last changed, so Nudge can tell it from a newer Xcode build. */
47let buildAt: number | null = null
48let lastCommand = ''
49let lastCatalog = ''
50const CATALOG_EVERY = 30
51
52/** The slash commands plugins add, by plugin, for Nudge to offer as buttons. */
53async function publishCatalog($: EngineInterface) {
54  const where = await folder($)
55  if (!where) return
56  const catalog: Record<string, { name: string; description: string }[]> = {}
57  for (const command of await $.command.list()) {
58    if (command.source !== 'plugin' || !command.plugin || command.plugin === 'nudge-bridge') continue
59    ;(catalog[command.plugin] ??= []).push({ name: command.name, description: command.description })
60  }
61  const body = JSON.stringify(catalog)
62  if (body === lastCatalog) return
63  lastCatalog = body
64  await $.fs.write(`${where.base}/mods.json`, body)
65}
66
67/** `/name args` when it names a command this session has; Nudge's mod buttons send these. */
68async function slashCommand($: EngineInterface, text: string) {
69  const match = /^\/(\S+)\s*([\s\S]*)$/.exec(text.trim())
70  if (!match) return null
71  const known = (await $.command.list()).some(listed => listed.name === match[1])
72  const [, command = '', args = ''] = match
73  return known ? { command, args } : null
74}
75
76async function folder($: EngineInterface): Promise<{ base: string; session: string } | null> {
77  const [home, session] = await Promise.all([$.env.get('HOME'), $.session.id()])
78  return home ? { base: `${home}/Library/Application Support/Nudge`, session } : null
79}
80
81async function publish($: EngineInterface) {
82  const [repo, project, build, ship, lint] = await Promise.all([
83    $.state.get(REPO), $.state.get(PROJECT), $.state.get(BUILD), $.state.get(SHIP), $.state.get(LINT),
84  ])
85  const feed = {
86    repo: repo.value ?? null,
87    project: project.value ?? null,
88    build: build.value ?? null,
89    ship: ship.value ?? null,
90    lint: lint.value ?? null,
91    // What this plugin offers; Nudge shows whatever is listed here.
92    actions: [
93      ...(project.value ? [{ id: 'build', title: 'Build', symbol: 'hammer' }, { id: 'lint', title: 'Lint', symbol: 'wand.and.stars' }] : []),
94      ...(repo.value ? [{ id: 'commit', title: 'Commit', symbol: 'checkmark.circle' }] : []),
95    ],
96    badges: lint.value && lint.value.checkedEdits > 0 && lint.value.issues > 0
97      ? [{ symbol: 'pencil.line', title: String(lint.value.issues), detail: lint.value.issues === 1 ? 'lint issue' : 'lint issues', tint: 'orange' }]
98      : [],
99  }
100  const body = JSON.stringify(feed)
101  if (body === lastWritten) return
102
103  const [where, cwd, now] = await Promise.all([folder($), $.session.cwd(), $.clock.now()])
104  if (!where) return
105  lastWritten = body
106  const buildJSON = JSON.stringify(feed.build)
107  if (lastBuild === null) {
108    lastBuild = buildJSON
109  } else if (buildJSON !== lastBuild) {
110    lastBuild = buildJSON
111    buildAt = feed.build ? Math.floor(now / 1000) : null
112  }
113  await $.fs.write(
114    `${where.base}/feeds/${where.session}.json`,
115    JSON.stringify({ session: where.session, cwd, updatedAt: Math.floor(now / 1000), buildAt, ...feed }),
116  )
117}
118
119async function takeCommand($: EngineInterface) {
120  const where = await folder($)
121  if (!where) return
122  const path = `${where.base}/commands/${where.session}.json`
123  const text = await $.fs.read(path).catch(() => '')
124  if (!text.trim()) return
125
126  let command: { id?: string; action?: string; prompt?: string }
127  try {
128    command = JSON.parse(text)
129  } catch {
130    return
131  }
132  if (!command.id || command.id === lastCommand) return
133  lastCommand = command.id
134  await $.fs.write(path, '')
135
136  const [repo, project] = await Promise.all([$.state.get(REPO), $.state.get(PROJECT)])
137  // A button made in Nudge's Settings arrives already worded; a mod's command runs as typed.
138  const slash = command.prompt ? await slashCommand($, command.prompt) : null
139  if (slash) {
140    $.clock.after(1, () => void $.command.run(slash).catch(() => {}))
141    return
142  }
143  const lines = command.prompt ? [command.prompt] : PROMPTS[command.action ?? '']?.(project.value ?? null, repo.value ?? null)
144  if (lines) $.clock.after(1, () => void $.prompt.submit({ text: lines.join('\n\n') }))
145}
146
147export const register: Register = on => {
148  on('session.start', async ($, e, next) => {
149    timer?.cancel()
150    let tick = 0
151    $.clock.after(1, () => void publishCatalog($).catch(() => {}))
152    timer = $.clock.every(INTERVAL_MS, () => {
153      void publish($).catch(() => {})
154      void takeCommand($).catch(() => {})
155      if (++tick % CATALOG_EVERY === 0) void publishCatalog($).catch(() => {})
156    })
157    return next(e)
158  })
159}
160
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