CLAUDE.md@apps/mac · git:20260915.a0ad064 · 2026-09-15 · sha256 2a68103d25f5b459

CLAUDE.md@apps/mac git:20260915.a0ad064A

Immutable. This exact content never changes and is served at /api/v1/blob/2a68103d25f5b459.

# Infinitus — project rules

Native macOS menu bar app, `apps/mac` of the Infinitus repo (#823). The
`native` branch is its frozen pre-#823 history, read-only.

## Non-negotiables
- **Infinitus is not tied to any one engine — forever** (user 2026-09-05,
  about cswap, the engine of the day: "Infinitus has nothing tight to
  cswap, not anymore. this is a forever decision"; cswap itself went in
  #756). swapd is one `AccountEngine` adapter among several; no feature,
  design, data format, CLI or publisher may depend on swapd existing.
  Anything cross-platform ships from THIS repo (InfinitusCore +
  InfinitusCLI on Swift for macOS/Linux; Windows unbuilt since #1268), never as an engine
  subcommand.
- **Everything is Swift; the engine is fully isolated.** Every engine
  touchpoint is a `swapd … --json` subprocess (InfinitusCore/Engines/Swapd/SwapdCLI.swift).
  Never read engine internals (`~/.claude-swap-backup/*`). Reading
  Claude Code's own files is fine: `~/.claude/settings.json`,
  `~/.claude/sessions/*.json` (+ `.key`), `~/.claude/projects/*/*.jsonl`.
- **Bundle id is `run.infinitus`**, the phone's `run.infinitus.mobile`
  (+`.widgets`, `.share`), every derived service id under the same
  prefix (user ruling 2026-09-05, paid Apple team `Q783W6B4FA`). App
  Support is `Infinitus/`; the local checkout may still live at
  `~/death/limitless`. Never change the id: Notification Center and
  login-item grants key on it and must be re-granted, keychain items are
  ACL'd to the old signature (proxy key re-entered, phone re-paired), and
  a casual change once cost a day of ControlCenter-ban debugging.
- **Improvements ship by default** (user 2026-09-09: "all of these are
  just experimental. any better improvements are by default valid"):
  a measured, tested improvement merges without a per-PR go — no
  holding PRs for approval, no AskUserQuestion for the obvious better
  option. Still ask before destructive or irreversible actions (a
  history rewrite, a force-push, deleting user data).
- **Push nothing to any remote** unless explicitly asked. Commit locally.
- **Layout (#823):** the Mac app lives in `apps/mac` of this repo's
  `main`, beside the T3 Code fork (TypeScript, never an upstream PR).
  Base Mac work on `origin/main`; a Mac PR is an ordinary main PR. The
  Mac app ships inside the one
  `v<version>` release (`.github/workflows/infinitus-release.yml`, the
  root `VERSION` file, `apps/mac/docs/RELEASING.md`): both Mac zips, the
  desktop DMG nesting the Menu Bar bundle, the Linux tray. `releases/latest`
  and the `nightly` tag are what AboutPane polls; older Mac releases were
  tagged `mac-v<version>` (AboutPane still strips that prefix).
- **`main` takes commits only through pull requests** (GitHub ruleset
  "main via pull requests"):
  0 required approvals (solo repo); the Mac checks required on a PR head
  are mac-test, mac-e2e, mac-linux (the best-effort Windows build left with #1268) —
  path-filtered, so a server-only PR passes them at once. Work on a
  branch, `gh pr create` (base main), merge with `gh pr merge --squash`
  (or `--merge` when the branch history matters) once tests pass;
  `--auto` queues the merge behind the checks. PRs get `size:*` and
  `vouch:*` labels automatically.
- **Every commit carries a `Co-Authored-By: Claude … <noreply@anthropic.com>`
  trailer** naming the model that did the work (user 2026-09-04: "some
  commits still missing Claude in author"); the harness supplies the
  name. `tools/githooks/prepare-commit-msg` appends one when a commit
  has none and accepts any Claude trailer; each clone/worktree owner
  runs `git config core.hooksPath tools/githooks` once. Subagent briefs
  say it explicitly.
- Secrets (tokens, sign-in codes) travel over stdin, never argv; shown
  masked only. Usage-cost figures are estimates, never billing truth.
- **Release notes: one feature, one line** (user 2026-09-04). A CHANGELOG
  bullet is a single short sentence — no multi-sentence paragraphs, no
  wrapped essays; details live in the site/README, not the note. A PR
  writes its line to `apps/mac/changelog.d/<pr>.md` as `Mac: …` (README
  there), not into CHANGELOG.md — parallel PRs used to conflict on the
  one Unreleased block every time; the release cut folds the fragments
  and commits the deletions the fold makes.
- Surgical changes; match existing style; no speculative abstractions.
- **Todos and research notes go to GitHub issues, never to files**
  (user 2026-09-04: "stop noting TODO file to avoid a PR, just log to
  issue"). `gh issue create` / `gh issue comment`; docs/TODO.md is the
  shipped log only.
- **Account policy lives in the engines** (user 2026-09-03). Auto-swap,
  pick-first, ordering come from each engine's own knobs (swapd
  `config`, the proxy's priority); the app only sets those and
  never runs a second policy on top (the app-side auto-order writer was
  removed for this). Missing knob → upstream PR, never a fork.
- **Keep performance in check with every feature** (user 2026-09-03):
  idle CPU with the pop-out open must stay near 0% (`infinitusctl perf`
  twice, 15s apart; `tools/e2e.sh` gates it in CI). Any continuous
  motion goes through Core Animation (`LayerEffect`), never a
  TimelineView / repeatForever `.animation` — see the hard-won fact.

## Hard-won facts
- NSPopover measures content once; wholesale content-shape swaps must go
  through `withAnimation` so it re-measures live. Never close/reopen.
- macOS ignores Dynamic Type — popup scaling is `PopupScale`
  (fixedSize → measure → scaleEffect + matching frame). Measuring without
  `fixedSize` feeds the scaled width back in and runs away ×scale.
- The pop-out window must NOT let NSHostingView size it (crash on
  unbounded ideal width); PinnedRoot reports its fixedSize geometry and
  `fitPinned` applies it.
- Never `cp` over the RUNNING unbundled binary — overwriting a signed
  executable in place gets the process killed on its next page-in
  (the dev instance "mysteriously died" 2026-08-30). Stop the instance
  first, by the PID you captured when you spawned it — never by name or
  pattern (AGENTS.md's first rule: several dev servers share this Mac).
- macOS 26 ControlCenter can stop adopting new bundled apps' status items
  after rapid relaunch churn — only a logout clears it; `run-unbundled.sh`
  is the workaround. Don't run the dev loop's kill/reopen cycle for hours.
- Never hide the status item with `NSStatusItem.isVisible` (#876): off
  then on left the app pulling WindowServer datagrams every frame on the
  CI runner (`remote_context_notify` → tracking-area + hosting-view
  layout each display cycle, idle 5–11% with Settings open; a Mac with a
  real GPU shows nothing). `menu_bar_enabled` off removes the item
  (`removeStatusItem`) and on installs a fresh one (`installItem()`),
  which idles at 1%. Tearing the layer effects down while hidden changed
  nothing — it is the item, not the animations.
- NSPopover windows refuse CABackdropLayer at every level (renders a
  black slab; probed 2026-08-30) — the anchored popup is therefore a
  borderless non-activating NSPanel. CABackdropLayer + CAFilter
  gaussianBlur in a plain window is the only tunable-blur glass.
- NSGlassEffectView does NOT deactivate when its window resigns key —
  the "goes solid unfocused" repro was the probe window being occluded.
  Never reintroduce a focus-swap around it; glass runs in all states.
- usernoted refuses dev-cert builds without a provisioning profile;
  notifications fall back to osascript (working mode, not an error).
- SwiftUI Grid: spanning cells span the widest row's real column count;
  placeholders need `.frame(maxWidth: .infinity)` +
  `.gridCellUnsizedAxes(.horizontal)`.
- macOS 26: a VStack of mixed text+gauge rows under-reports its ideal
  HEIGHT under two-axis fixedSize (last row clips to slivers) — give
  every such row its own `.fixedSize()`.
- A GridRow can't wear a modifier (collapses to one cell), but a Group
  INSIDE it distributes the modifier to every cell — that's how the
  rows intro slides a whole grid row.
- Multi-engine (#8): every engine is an `AccountEngine` yielding
  `EngineFleet`s; the popup stacks one `FleetState` per fleet and
  `AppModel` is only a FleetModel FACADE over the primary Claude fleet.
  Gate UI on `capabilities`, never on engine identity.
- The CLIProxyAPI key lives in the keychain (`run.infinitus.cliproxy`,
  account = base URL). Unsigned debug binaries trip an ACL prompt on
  every rebuild — reads skip UI, and the dev loop codesigns the debug
  binary with the Apple Development identity so the grant sticks.
- A dev instance never pushes alerts from the shipped app's
  APNs key (#845): the `.p8` item's decrypt ACL names `Infinitus.app`
  only, `Keychain.read` skips UI, so the dev-signed binary reads nil and
  the pusher stays unconfigured — silently (no last-result line, no
  event). Verify push changes on the tagged build; never send the key
  to a dev instance's `apns-key` verb — through the desktop's Devices
  page or `infinitusctl` (`Keychain.write` deletes the shipped app's
  item first).
- Every SwiftUI-driven frame (TimelineView tick, repeatForever
  `.animation`) commits a CA transaction: display-list diff, AppKit
  drag-region + tracking-area rebuild, a WindowServer fence — ~7 ms
  each, the same whether one leaf or the whole grid changed. Five RPG
  effects at 20 fps idled the pop-out at 43% CPU (#18, 2026-09-03);
  as CAAnimations on a LayerEffect host they idle at 0.4%, burn
  overlays included (CAEmitterLayer sparks; a `.line` emitter's
  emissionLongitude is a quarter turn off a `.point` one's: 0 = up).
  A per-second `.contentTransition(.numericText)` grows the CG glyph
  cache ~2 MB/min for as long as it ticks (macOS 26) — never on a
  countdown; the e2e gate checks idle heap growth via `perf.heapBytes`.
  An ordered-out window keeps its SwiftUI content ticking (the wall's
  15 fps TimelineView cost ~8% idle after every visit): detach the
  hosting controller on close, and reuse the NSWindow — a closed
  borderless one lingers in AppKit's list regardless.
- Dev instances: sign the debug binary `--identifier
  run.infinitus` (tools/e2e.sh does) or the keychain ACL prompt
  blocks AppModel.init forever (no socket, SecurityAgent spawns).
  `swift build --target X` may not relink — use `--product`, ONE per
  invocation: with two `--product` flags SwiftPM builds only the last
  (CI's e2e ran a stale app binary for a day, 2026-09-03).
- Several Claude sessions work this repo: each in its OWN worktree
  (one `cd` there; separate `apps/mac/.build`). The main checkout
  (`~/death/limitless`) is merge-only (`git pull --ff-only origin main`)
  and owned by the coordinating session. Since 2026-09-12 the user's
  menu bar is the helper nested in Infinitus desktop
  (`/Applications/Infinitus.app/Contents/Library/LoginItems`), so nothing
  relaunches a local `Infinitus.app` on the real control socket: Mac
  changes reach the user through the one `v<version>` release, which
  nests the Menu Bar bundle of the same sha (#777, #823). Dev instances run on
  `INFINITUS_CONTROL_SOCKET`. Never edit another session's tree; in any
  tree stage by explicit path.
- Linux corelibs `Process`: one waited on through its `terminationHandler`
  alone is never freed — its run-loop source retains it back and
  `CFRunLoopSourceInvalidate` keeps that context on purpose; only
  `waitUntilExit()` (instant once the child is gone) clears the source.
  Every leaked child keeps two pipe ends, and at ~1,300 descriptors
  `Process.run()`'s `/proc/self/fd` walk reads past its readdir buffer
  (signal 11, #510; upstream fix not in any shipped toolchain). Darwin's
  Foundation has neither problem. `.github/workflows/linux-sanitize.yml`
  runs the suite under ASan on demand.

## Release
- Every release updates **site/** (infinitus.run) and the **GitHub
  README** with the new features so app, site and README stay in sync
  (user 2026-09-03). Do it in the release commit, not after.
- **The site deploys by hand** (ruling 2026-09-12, #823: no deploy
  workflow, none ever existed): after the release merges, from
  `apps/mac/site` run `npx wrangler deploy` (the Cloudflare login is the
  user's; the custom domain and the RENDEZVOUS KV bind from
  `wrangler.jsonc`). Nothing in CI touches infinitus.run.

- Any dev/smoke instance of the app (debug binary, `-mock_mode`, a
  second bundle) MUST run with `INFINITUS_CONTROL_SOCKET=/tmp/<short>.sock`
  — without it, ControlServer.start() unlinks the real app's control
  socket and `infinitusctl`/the phone get "connection refused" until
  the bundle relaunches (bit us 2026-09-03 08:21). Short path: unix
  sockets cap at ~104 bytes, so never the scratchpad dir. It also needs
  `INFINITUS_APP_SUPPORT=<state-dir>/app-support`, or every file
  it writes (stats caches, events…) lands in
  the real Application Support/Infinitus (#506; the stats clobber cost
  each relaunch a year's re-read, #346).

## Build / run / test
`./make-app.sh && open Infinitus.app` · `swift test` · `./dev.sh` (entr)
· `./run-unbundled.sh` (menu bar wedge workaround).