AGENTS.md · git:20261030.90f4351 · 2026-10-30 · sha256 0cbb2fa638f4f131

AGENTS.md git:20261030.90f4351A

Immutable. This exact content never changes and is served at /api/v1/blob/0cbb2fa638f4f131.

# AGENTS.md

## Build & Run

The Swift package lives in `src/`. Run all `swift build`, `swift run`, and `swift package` commands from there, not from the repo root.

```bash
# Preferred dev loop: kill any running DroidProxy, rebuild the .app bundle, and
# launch the freshly signed build. Use this instead of running create-app-bundle.sh
# + open by hand — it guarantees the old menu-bar process and bundled
# cli-proxy-api are stopped before the new app starts.
./dev-relaunch.sh

# Debug build (no .app bundle, no relaunch)
cd src && swift build

# Run the app manually (menu bar app — swift run does not work for LSUIElement apps)
# Build the .app bundle first, then open it:
./create-app-bundle.sh && open DroidProxy.app

# Release .app bundle at repo root
# Picks up CODESIGN_IDENTITY / APP_VERSION / TARGET_ARCH from env when present
./create-app-bundle.sh
```

`dev-relaunch.sh` is the preferred way to run DroidProxy during development. It calls `create-app-bundle.sh` (which runs `swift build -c release` and assembles the signed `.app`) after killing any running `CLIProxyMenuBar` / `cli-proxy-api` processes, then launches the fresh bundle. Do not use it for releases — those go through `.github/workflows/release.yml`.

`create-app-bundle.sh` currently builds `DroidProxy.app` at the repo root and bundles resources from `src/Sources/Resources/`.

### Notarization (local)

```bash
ditto -c -k --sequesterRsrc --keepParent "DroidProxy.app" "DroidProxy-notarize.zip"
xcrun notarytool submit "DroidProxy-notarize.zip" --keychain-profile "notarytool" --wait
xcrun stapler staple "DroidProxy.app"
```

### Sparkle update signing

```bash
src/.build/artifacts/sparkle/Sparkle/bin/sign_update DroidProxy-arm64.zip
```

## Source Of Truth

The compiled app code is under `src/`. Treat `src/Sources/**`, `src/Info.plist`, and `create-app-bundle.sh` as source of truth. There is no longer a mirrored top-level `resources/` tree — older AGENTS notes about it are stale.

## Architecture

DroidProxy is a macOS menu bar app (`LSUIElement`) with:

1. `ThinkingProxy` on `localhost:8317`, the user-facing TCP proxy.
2. Bundled `CLIProxyAPI` on `127.0.0.1:8318`, managed as a child process by `ServerManager`.
3. A separate localhost-only Copilot API gateway on `127.0.0.1:8319`, managed by `CopilotGatewayManager`.

Typical request flow:

`Client -> :8317 ThinkingProxy -> :8318 CLIProxyAPI -> upstream provider`

Selected GitHub Copilot models instead use:

`Client -> :8319 Copilot API gateway -> GitHub Copilot`

Meta Muse models (`muse-wonder-1.0`, `muse-wonder-1.0-contributor`, `muse-wonder-full-1.0`, `muse-wonder-full-1.0-contributor`) split by API:

- **Responses** (`/v1/responses`, `/responses/compact`): ThinkingProxy TLS-forwards to `https://api.meta.ai/v1` with the minted Model API key. CLIProxyAPI's generic `openai-compatibility` executor otherwise rewrites Responses into `/chat/completions`, which drops `reasoning.encrypted_content` and breaks long Muse/Foundry runs.
- **Chat Completions**: still `:8317 -> :8318 -> api.meta.ai` via the `openai-compatibility` block `ServerManager` writes into `merged-config.yaml`, so multi-account failover keeps working for Completions clients.

### Current ThinkingProxy behavior

Reasoning effort is owned by **Droid CLI**, not the proxy. Each Factory custom model is registered with native reasoning metadata (`enableThinking`, `supportedReasoningEfforts`, `defaultReasoningEffort`, `reasoningEffort`) so Droid's per-session selector exposes every level the model supports, and Droid sends the chosen value in the request body. The proxy does **not** inject `thinking`, `reasoning`, `reasoning_effort`, `output_config`, `budget_tokens`, or `generationConfig.thinkingConfig` for any model — it forwards the request unchanged.

What it still does today:

- **Anthropic-Beta rewriting**: When a Claude request has `thinking.type` of `enabled`/`adaptive`/`auto`, the proxy strips `redact-thinking-2026-02-12` from the `Anthropic-Beta` header and appends the visible-thinking beta list (interleaved-thinking, prompt-caching-scope, etc.). It never injects `fast-mode-*` and always strips it if Factory/Droid sent it — CLIProxyAPI treats any Fast-marked 429 as request-scoped, which blocks OAuth seat failover. Claude has no Fast mode. Without the redact-thinking strip, Claude emits only signed empty thinking blocks.
- **Service tier (fast mode)** for Responses API paths (`/v1/responses`, `/api/v1/responses`): injects `"service_tier":"priority"` for `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6.1-terra`, or `gpt-6-luna` when `AppPreferences.gpt6AstraFastMode` / `gpt61SolFastMode` / `gpt61TerraFastMode` / `gpt6LunaFastMode` is enabled and the client did not already set `service_tier`. Fast mode is API priority and is independent of reasoning effort.
- **Grok routing**: every Grok chat/Responses model goes to `cli-chat-proxy.grok.com` (Grok Build billing). Never send them to `api.x.ai`: the same OAuth bearer there is billed as X API usage (`productUsage` `Api` instead of `GrokBuild` in `/v1/billing?format=credits`). Only `grok-imagine-*` image requests still use `api.x.ai`. `grok-4.7-build-fast` is a separate SuperGrok catalog model (same OAuth bearer as `grok-4.7`) that the public API does not serve. ThinkingProxy sends both with `X-XAI-Token-Auth: xai-grok-cli`, `x-grok-model-override: <requested model id>` (`grok-4.7` or `grok-4.7-build-fast`), `x-grok-client-version`, and `x-grok-client-identifier: grok-shell`. The proxy rejects a missing client version. The JSON `model` field stays `grok-4.7-build-fast`. Reasoning effort stays whatever Droid put in the body.
- **Grok native tool-call rewrite**: Grok often leaks Factory markup (`<|tool_calls_begin|>` / `<|tool_call_begin|>`) into `message.content`, or a fenced `{"name","arguments"}` JSON object. Factory's first-party Grok adapter parses the markup; the custom OpenAI route does not, so the worker turn ends as plain text. The proxy buffers Grok responses and lifts markup or JSON into OpenAI `tool_calls` before the client sees it.
- **Grok EndFeatureRun repair**: Grok often calls `EndFeatureRun` without `handoff`, or with `validatorsPassed` as a thought-string. Factory rejects those and the worker loops. The proxy inserts a stub `handoff` and coerces `validatorsPassed` before the client sees the call.
- **Grok Execute repair**: Grok often calls `Execute` with only `summary` (or `cmd`/`input`/`shell`) and Factory rejects it (`command is required`). The proxy remaps aliases, lifts a command embedded in `summary` or a same-turn fenced code block, and promotes a summary that is already a shell string. It does **not** invent a command from English. On the request path, `GrokRequestSanitizer` also pins `command` as a required Execute/Bash/Shell parameter so xAI sees the field.
- **Gemini path rewrite**: `/v1/responses` (and `/api/v1/responses`) are rewritten to `/v1/chat/completions` for OAuth Code Assist Gemini models (the `-preview`-suffixed names) since CLIProxyAPI does not support those via the Responses API endpoint.
- **Meta Muse Responses TLS-forward**: `muse-wonder-1.0` / `muse-wonder-1.0-contributor` / `muse-wonder-full-1.0` / `muse-wonder-full-1.0-contributor` POSTs to `/v1/responses` (and `/responses/compact`) skip CLIProxyAPI and go to `api.meta.ai` with the minted Model API key, preserving encrypted reasoning. Chat Completions for those models still use the `openai-compatibility` block. All four variants accept reasoning efforts `minimal` through `max` (live-verified; `none`/`ultra` return 400 and are not advertised).
- **Meta usage sniffing**: Meta has no usage endpoint, so the proxy inspects (never modifies) relayed Meta Responses bytes for `response.subscription_usage` SSE events (`window` 5h + `weekly` percents with `resets_at`) and records them per serving account into `MetaMuseUsageStore`, which backs the Meta cards in OAuth Quota Usage with `muse /usage`-style "as of" semantics.
- **Usage endpoint**: `GET /droidproxy/usage[?provider=codex|claude|grok|meta&account=<id|email>]` on `:8317` is answered locally by `UsageEndpoint` (never forwarded) with live quota windows as JSON (`remaining_percent`, `resets_at`, `resets_in_seconds` per window). Clicking a gauge in OAuth Quota Usage copies a `curl` for that account so an agent can poll its own quota and stop before it runs out (Droid gives up retrying failed requests after ~30s). No auth or loopback restriction, by design.
- **Control API**: `/droidproxy/control/` on `:8317` is answered locally by `ControlAPI` (never forwarded). See "Control API" below.
- **Per-request reasoning log** to `/tmp/droidproxy-debug.log`: each `POST` emits a `REQUEST REASONING:` line that extracts just `reasoning` / `reasoning_effort` / `thinking` / `output_config` / `service_tier` / `generationConfig` from the parsed body so the actual values Droid is sending are visible without dumping the whole prompt. Example: `REQUEST REASONING: model=gpt-6.1-sol reasoning={"effort":"high","summary":"auto"}`.
- Preserves JSON key order by editing the raw JSON string instead of re-serializing (critical for Anthropic's prompt cache). The remaining helpers (`injectJSONField`, `findTopLevelFieldLocation`, etc.) exist for `processOpenAIFastMode`.

What it no longer does (removed in the Droid-CLI-thinking refactor):

- No Claude adaptive thinking injection (Opus 4.8 / Sonnet 5 — `thinking` + `output_config`)
- No classic `thinking.budget_tokens` injection
- No Codex `reasoning.effort` injection
- No Gemini `generationConfig.thinkingConfig` injection
- No Kimi `reasoning_effort` injection
- No `claude-opus-4-8(high)` / `gpt-5.2(xhigh)` etc. “advanced variant” suffix parsing — every level now ships in the single base entry via Droid CLI metadata
- No Max Budget Mode override
- No Amp CLI routing (the `/auth/cli-login` redirect, `/provider/*` rewrite, `ampcode.com` management forwarding, and Amp response normalization were removed when switching to mainline CLIProxyAPI)

### Control API

Version 1 of the API Droid Office uses to drive DroidProxy (its contract is `docs/subscriptions.md` in nikships/droid-office). ThinkingProxy serves it on its own port under `/droidproxy/control/`; parsing, authorization, routing and JSON live in `ControlAPI` (pure, unit tested), the work in `ProviderController`.

- **Discovery.** At launch DroidProxy writes `~/.droidproxy/control.json` (folder 0700, file 0600, temp file and rename): `{ "api": 1, "port": 8317, "token": "<64 hex>", "version", "pid" }`. The token is 32 bytes from `SecRandomCopyBytes`, new at every launch, and is never logged. The file is removed at quit.
- **Every request** needs `Authorization: Bearer <token>` (compared in constant time; else 401) and is refused with 403 from a non-loopback peer, with any `Origin` header, or with a `Host` other than `127.0.0.1:<port>` or `localhost:<port>`. A POST without `Content-Type: application/json` is 415; a body over 64 KiB is 413 (refused from its `Content-Length` before it is read). Errors are `{ "error": "<words for the owner>" }`.

| Call | Does | Answers |
| --- | --- | --- |
| `GET /status` | Version, backend, providers (switch, accounts, a sign-in in flight or its error), Droid's models | `{ api, version, backend: { running }, providers: [{ key, name, enabled, connect, accounts: [{ id, label, expired, disabled }], connecting }], factory: { installed, available, stale } }` |
| `POST /connect` `{ provider }` | Starts the provider's sign-in, as Add Account does | 202 `{ connecting: { started_at, user_code, verification_uri, error } }`; 409 while one runs; 400 for Junie (`connect: "app"`) |
| `POST /connect/cancel` `{ provider }` | Stops it | 200 `{}` |
| `POST /accounts/remove` `{ provider, account }` | Forgets an account (by id or email; Copilot's is `github_token`) | 200 status; 404 for an unknown account; 409 when an email names several (a Claude email can hold a seat in several organizations) |
| `POST /providers` `{ provider, enabled }` | The provider switch (hot reload) | 200 status |
| `POST /factory-models` `{}` | Apply Factory Models, backup and all | 200 `{ installed, backup }` |
| `GET /models` | Whether each catalogue model can run now | `{ generated_at, models: [{ id, name, provider, availability }] }` |
| `POST /open` `{}` | Brings up the Settings window | 200 `{}` |

- **Availability** (`AvailabilityJudge`): `disabled` when the provider is off; `signed-out` with no account that isn't expired or disabled (Grok: the active credential only); `ok` when any such account has every covering window above 0% or no usage to go by, `unknown` when none of them has usage (and always for Antigravity, Kimi, Junie and Copilot, which have no usage reader); else `exhausted` with the window and `until` of the account that comes back first (an account is back when the last of its exhausted windows resets). A window covers every model of its provider except Claude's family windows (`Weekly (Opus)` only Opus, `Weekly (Sonnet)` only Sonnet) and Claude's other-product windows (`Weekly (OAuth Apps)`, `(Cowork)`, `(Omelette)`), which cover none.
- **Usage source** (`ControlUsage`): route every Codex, Claude and Grok quota fetch through `OAuthUsageTracker.fetchUsage` and its shared `OAuthUsageCache`. The cache keys by provider and credential-file path, not endpoint filter or bearer token, and coalesces in-flight reads across `/models`, `/usage` and Settings. Claude waits at least five minutes plus 0–30 s jitter after each attempt; 429 fallback waits grow 5/10/20/40/60 minutes, never shorter than `Retry-After`. Other providers retain a 15 s interval. Failed reads preserve the last successful windows and sample time while carrying the error. Samples, request reservations and cooldowns survive restart in `~/.droidproxy/usage-cache.json` (0600, no tokens). Do not bypass the cache on manual refresh or token rotation. Meta comes from its stored snapshot without a probe in `/models`, so polling models never spends Meta quota.
- **Launched by Droid Office.** `open -g -a DroidProxy.app --args --launched-by droid-office` runs normally but shows nothing of its own: no notification prompt or "Server Started" banner, and Sparkle starts only when the owner opens Settings or Check for Updates (`LaunchContext`).

## Auth And Providers

The current app/UI exposes these provider types:

- `claude`
- `codex`
- `gemini`
- `kimi`
- `copilot` (device-code OAuth; credentials stay in `~/.droidproxy/copilot-api/`; the separate local Copilot API gateway serves only the user-selected models)
- `meta` (Meta Muse subscription; device-code OAuth against `auth.meta.com` reproduces the `muse` CLI's own login flow, then mints a Model API key from `api.meta.ai/muse-code/key`. Completions keys are written into a generic `openai-compatibility` block in `merged-config.yaml`; Responses are TLS-forwarded by ThinkingProxy. This contract is reverse-engineered from the `muse` binary and a third-party client, not official docs.)

Auth data for `AuthManager`-managed providers lives in `~/.cli-proxy-api/` as JSON files. Copilot is the exception: its gateway owns `~/.droidproxy/copilot-api/github_token`, which DroidProxy never reads. Meta Muse is a second exception: its per-account identity tokens and minted Model API keys live in `~/.droidproxy/meta/accounts.json` (mode 0600, parent directory 0700; legacy `credentials.json` is migrated automatically), never in `~/.cli-proxy-api/`.

- `type`
- `email`
- `login`
- `expired`
- `disabled`

Behavior to know:

- Multiple accounts per provider are supported
- Per-account disable/enable is supported via the `disabled` field in each auth JSON
- The last enabled account for a provider cannot be disabled
- Provider-level toggles in `SettingsView` are separate from per-account disable flags
- Provider-level disable writes `oauth-excluded-models` into `~/.cli-proxy-api/merged-config.yaml`
- `CLIProxyAPI` hot-reloads config changes, so provider enable/disable does not require a restart
- The app watches `~/.cli-proxy-api/` for changes from both `AppDelegate` and `SettingsView`

## Key Files

| File | Role |
|---|---|
| `src/Sources/main.swift` | NSApplication entry point that instantiates `AppDelegate` and calls `NSApplicationMain`. |
| `src/Sources/AppDelegate.swift` | App lifecycle, menu bar UI, settings window, notifications, Sparkle updater, auth-directory watcher, startup ordering for the two local servers. |
| `src/Sources/ServerManager.swift` | Starts/stops bundled `cli-proxy-api`, captures logs, merges config (including injecting the remote-management `allow-remote`/`secret-key` settings from UserDefaults), handles provider enable/disable, runs Claude/Codex/Gemini login commands, and kills orphaned backend processes. |
| `src/Sources/ThinkingProxy.swift` | Raw TCP HTTP proxy that forwards requests to CLIProxyAPI (and TLS-forwards Junie/Grok/Meta Responses). Rewrites the Anthropic-Beta header to drop `redact-thinking-2026-02-12` on Claude thinking requests and always strips `fast-mode-*` on Claude traffic, injects `service_tier=priority` on enabled Codex fast-mode models, rewrites OAuth Code Assist Gemini `/v1/responses` to `/v1/chat/completions`, sanitizes Grok tool types, sends all Grok chat models to `cli-chat-proxy.grok.com` (Grok Imagine to `api.x.ai`), lifts Grok tool markup into OpenAI `tool_calls`, repairs Grok `EndFeatureRun`/`Execute` arguments, TLS-forwards Muse Wonder `/v1/responses` to `api.meta.ai`, sniffs `response.subscription_usage` SSE events from Meta Responses streams into `MetaMuseUsageStore` (relayed bytes pass through unchanged), and emits a `REQUEST REASONING` log line per request. Does not inject reasoning or thinking fields. |
| `src/Sources/ControlAPI.swift` | The control API's pure parts: request parsing, authorization (loopback, `Origin`, `Host`, bearer token, content type, body size), routing, JSON shapes, the `ControlBackend` protocol, and `ControlDiscovery` (the token and `~/.droidproxy/control.json`). |
| `src/Sources/ProviderController.swift` | Connect (browser, device code), cancel, remove account, the provider switch and Droid's models, shared by `SettingsView` and the control API. Tracks each provider's sign-in in flight (`started_at`, device code, the error until the next connect). |
| `src/Sources/ProviderController+Control.swift` | `ProviderController` as the control API's backend, and `ControlUsage` (the usage `/models` judges by). |
| `src/Sources/ModelAvailability.swift` | `AvailabilityJudge`: whether a model can run from its provider switch, accounts and usage windows. |
| `src/Sources/FactoryModels.swift` | Reads and merges DroidProxy's entries in `~/.factory/settings.json` (`customModels`), with the timestamped backup. |
| `src/Sources/LaunchContext.swift` | Recognizes a launch by Droid Office (`--launched-by droid-office`). |
| `src/Sources/UsageEndpoint.swift` | Serves `GET /droidproxy/usage` JSON for ThinkingProxy and builds the `curl` command the Settings gauges copy on click. Reuses `OAuthUsageTracker.fetchUsage` and `AuthManager.loadAccountsSnapshot`. |
| `src/Sources/MetaMuseUpstream.swift` | Pure helpers: Muse model detection, Responses-path detection, and `api.meta.ai` path normalization used by ThinkingProxy's TLS forward. |
| `src/Sources/ClaudeAnthropicBetaRewriter.swift` | Strips `fast-mode-*` from Claude `Anthropic-Beta` headers (never injects it) and, on thinking requests, drops `redact-thinking-2026-02-12` then appends the visible-thinking beta list. |
| `src/Sources/GrokAuth.swift` | Grok device-flow OAuth, token refresh (single-flight + terminal-refresh quarantine), path/header helpers. all Grok chat models → `cli-chat-proxy.grok.com` plus `x-grok-model-override`; `grok-imagine-*` → `api.x.ai`. |
| `src/Sources/GrokRequestSanitizer.swift` | Remaps Factory `custom` tools to `function` (and drops unsupported types) before Grok upstream. |
| `src/Sources/GrokNativeToolCallRewriter.swift` | Lifts Factory/Droid `<\|tool_calls_begin\|>` markup out of Grok chat-completion content into OpenAI `tool_calls` (JSON, SSE, and full HTTP responses). |
| `src/Sources/GrokEndFeatureRunRepair.swift` | Repairs Grok `EndFeatureRun` arguments that omit `handoff` or send a non-bool `validatorsPassed`, and `Execute`/`Read`/`Grep` calls that omit `command`/`file_path`/`pattern` or use aliases. |
| `src/Sources/DroidProxyModelCatalog.swift` | Authoritative catalog of DroidProxy-exposed models. Each `DroidProxyModelDefinition` carries its supported `levels` plus a `defaultLevelValue`, and `settingsEntry` always embeds Factory's native reasoning metadata (`enableThinking`, `supportedReasoningEfforts`, `defaultReasoningEffort`, `reasoningEffort`) so Droid CLI's per-session selector can expose the full level set. |
| `src/Sources/CopilotSupport.swift` | Local `@jeffreycao/copilot-api` gateway lifecycle (`CopilotGatewayState` of `idle`/`starting`/`running`/`failed`, with a `/v1/models` readiness probe so a gateway that exits before binding its port surfaces as `failed`), device-code authentication, account-specific model discovery, and persistence for at most three Factory-selected Copilot models. |
| `src/Sources/MetaMuseSupport.swift` | `MetaMuseAuthManager` reproduces `muse login`'s device-code flow entirely in-process (no child process): device authorization + token polling against `auth.meta.com`, then Model API key minting against `api.meta.ai/muse-code/key`. `AppDelegate` checks hourly and independently refreshes enabled accounts within 6h of their ~24h key expiry. Completions keys are written into CLIProxyAPI; Responses skip it. |
| `src/Sources/MetaMuseCredentialStore.swift` | Private multi-account persistence, legacy migration, account disable/remove, and stale-refresh protection. `ServerManager` emits all enabled, unexpired keys as `api-key-entries` for Completions round-robin/sequential failover. Responses TLS-forward uses the first usable key. `.metaAccountsChanged` triggers config hot reload and the shared Settings account list. |
| `src/Sources/WindowPrimer.swift` | Opt-in "Auto-start daily" for Claude, Codex, and Meta 5-hour windows (rows in Settings → Services via `WindowPrimerRow.swift`; per-provider toggle + time in UserDefaults `windowPrimerEnabled.<p>` / `windowPrimerMinutes.<p>`, default 07:00). A 30s timer in `AppDelegate` fires once a day at or after the chosen time (2h catch-up, 5-minute retry on failure) and sends one tiny request per enabled account: Meta directly via `MetaMuseUsageProbe` (forced), Claude/Codex through the local proxy (`claude-haiku-4-5-20251001` `/v1/messages` max_tokens 1; `gpt-6-luna` `/v1/chat/completions`). The proxy picks the account, so each account gets a request but placement is the router's. Starting the window early makes it reset mid-workday; weekly caps still apply. |
| `src/Sources/MetaMuseUsageProbe.swift` | Refreshes a stale Meta snapshot (older than 60s) by streaming a 16-token `/v1/responses` request with the account's own key and recording the `response.subscription_usage` event, then dropping the stream. Used by `OAuthUsageTracker.fetchMetaUsage`, so both the Settings refresh and `/droidproxy/usage` get live Meta numbers even when Meta is idle. Costs a few tokens of quota per probe; failures fall back to the stored snapshot. |
| `src/Sources/MetaMuseUsageStore.swift` | Last-observed Meta subscription quota per account id (`~/.droidproxy/meta/usage.json`). Meta has no usage endpoint — the 5-hour window and weekly percents arrive as `response.subscription_usage` SSE events that ThinkingProxy sniffs into this store (same "as of last response" semantics as `muse /usage`). Snapshots older than 60s are refreshed by `MetaMuseUsageProbe` (see below). `.metaUsageChanged` drives live Settings card updates without refetching other providers. |
| `src/Sources/SettingsView.swift` | SwiftUI settings UI for server status, launch-at-login, provider toggles, auth flows (through `ProviderController`), the Codex fast-mode (`service_tier=priority`) subsection, the Copilot gateway status row (renders the `failed` reason plus a Retry button), the Factory custom-models Apply button, OLED theme, background opacity, and remote-access settings. No thinking/reasoning selectors — those live in Droid CLI. |
| `src/Sources/AuthStatus.swift` | `AuthManager`, account parsing, expiry detection, file deletion, and per-account disabled-state updates. |
| `src/Sources/AppPreferences.swift` | UserDefaults-backed preferences: fast-mode toggles for GPT 6 Astra/6.1 Sol/6.1 Terra/Luna; `allowRemote`, `secretKey`, `oledTheme`, `backgroundOpacity`, `verboseLogging`. No thinking-effort keys — reasoning is driven entirely by Droid CLI. |
| `src/Sources/OAuthUsageTracker.swift` | Reads Codex/Claude OAuth quota windows, the SuperGrok pooled credit window (`cli-chat-proxy.grok.com/v1/billing?format=credits`, bearer from `GrokAuth.ensureValidAccessToken`), and Meta usage from `MetaMuseUsageStore`, refreshed by `MetaMuseUsageProbe` when the stored snapshot is over 60s old (a failed probe falls back to the stored "as of" value; the live `updateMetaAccounts` path only reads the store) for the "OAuth Quota Usage" section in `SettingsView`. Codex window titles come from `limit_window_seconds` because plans differ (5-hour + weekly, weekly only, or monthly only); Claude always has 5-hour + weekly; Meta has 5-hour + weekly with an "as of" stamp; SuperGrok has one weekly window. Owns its own refresh button; there is no menu-bar usage display. |
| `src/Sources/OAuthUsageViews.swift` | Compact quota dashboard: a wrapping flow of uniform dual-ring gauges with no per-account boxes (provider logo inside each ring; outer track stays empty without a second limit), 3+ windows stack half-size. An email header only appears above a provider's rings when it has several accounts. Depends only on the usage model types, so it can be rendered outside the full app. |
| `src/Sources/NotificationNames.swift` | Shared `Notification.Name` constants (`serverStatusChanged`, `authDirectoryChanged`). |
| `src/Sources/IconCatalog.swift` | Caches `NSImage` lookups from the bundle's resource path so menu-bar / settings icons aren't re-decoded per access. |
| `src/Sources/LogoView.swift` | Inline-SVG `LogoView` used in the settings UI. |
| `src/Sources/AuthDirectoryMonitor.swift` | Debounced `DispatchSource` watcher on `~/.cli-proxy-api` that fires an `onChange` callback when auth JSON files are added, changed, or removed. Used by both `AppDelegate` and `SettingsView`. |
| `src/Sources/AuthPaths.swift` | Single source of truth for the auth directory location (`~/.cli-proxy-api`). |
| `src/Sources/Resources/config.yaml` | Bundled CLIProxyAPI config (`port: 8318`, localhost binding, auth dir). |
| `src/Info.plist` | Bundle metadata. Current source-of-truth values include app name `DroidProxy`, bundle ID `com.droidproxy.app`, and Sparkle feed URL on `anand-92/droidproxy`. |

## Conventions

- Use `NSLog`, not `print` or `os_log`
- Source-of-truth edits land under `src/` (especially `src/Sources/**`, `src/Sources/Resources/`, `src/Info.plist`) and `create-app-bundle.sh` at the repo root; there is no longer a parallel top-level `resources/` mirror
- Treat `DroidProxy.app`, `CLIProxyMenuBar`, and `com.droidproxy.app` as the active app identity
- `CLIProxyAPI` is bundled as `src/Sources/Resources/cli-proxy-api`
- `ThinkingProxy` uses surgical string insertion for JSON edits to preserve cache-sensitive key ordering (do not switch to `JSONSerialization.data` round-trips)
- Local backend traffic is intended to stay on localhost only (`127.0.0.1:8318`)

## Release Notes For Agents

Release automation lives in `.github/workflows/release.yml` (no `Makefile` or `scripts/create-release.sh` in this repo). The app ships as a single arm64 build; there is no x86_64 appcast or Intel release path.

If a task touches release tooling, audit the current workflow and `create-app-bundle.sh`.

### Models that wait on a CLIProxyAPI release

CLIProxyAPI resolves providers from its model registry only, so a catalog PR for a model the bundled binary does not list yet fails with `unknown provider for model <id>`. To ship such a PR together with the bump that fixes it:

1. Add the `awaiting-cliproxyapi` label to the PR.
2. Put one `Requires-CLIProxyAPI-Model: <model-id>` line per model in the PR body.

When `update-cliproxyapi.yml` opens the next `bump-cliproxyapi-*` PR, `.github/workflows/merge-gated-prs.yml` checks whether the bumped binary's embedded registry lists every declared id. If so it merges the gated branch into the bump branch, waits for the `Swift Build` check, squash-merges the combined PR (one release), and closes the gated PR. PRs whose models are still missing are left alone. Re-run it manually with the workflow's `workflow_dispatch` (`bump_pr`, optional `dry_run`).