diff --git a/.agents/README.md b/.agents/README.md index 286a646a6..fb2982809 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,80 +1,32 @@ -# Build macOS Apps — Skills Bundle +# Agent Skills -Local skills bundle ported from OpenAI's Codex plugin -[`openai/plugins/build-macos-apps`](https://github.com/openai/plugins/tree/main/plugins/build-macos-apps). -Packages macOS-first development workflows (Xcode, Swift, SwiftPM, SwiftUI, -AppKit, signing, telemetry) as agent skills. +Skills that agents (Claude Code, Codex, Cursor) can load from this repo. `.claude` is a symlink to `.agents` so Claude Code finds them. -## Layout +## Runway skills -```text -.agents/ -├── README.md # this file -└── skills/ - ├── appkit-interop/SKILL.md (+ references/*.md) - ├── build-run-debug/SKILL.md (+ references/build-script.md) - ├── liquid-glass/SKILL.md - ├── packaging-notarization/SKILL.md - ├── signing-entitlements/SKILL.md - ├── swiftpm-macos/SKILL.md - ├── swiftui-patterns/SKILL.md (+ references/*.md) - ├── telemetry/SKILL.md - ├── test-triage/SKILL.md - ├── view-refactor/SKILL.md - ├── window-management/SKILL.md - ├── macos-*/SKILL.md # macos-prefixed variants of the skills above - │ - ├── pricing-update/SKILL.md # Runway project skill, not from the plugin - ├── release-swift/SKILL.md # Runway project skill, not from the plugin - │ - ├── build-and-run-macos-app/SKILL.md # ex-slash-command - ├── fix-codesign-error/SKILL.md # ex-slash-command - └── test-macos-app/SKILL.md # ex-slash-command +- `release-swift/`: cut a stable release (version, changelog, tag, publish notes, verify). +- `pricing-update/`: sync `pricing_supplement.json` with Cursor's published model pricing and open a PR. -.claude -> .agents # symlink so Claude Code finds them -``` +## macOS development skills -## Conversion notes (vs the source plugin) +The `macos-*` skills are a local copy of OpenAI's Codex plugin [`openai/plugins/build-macos-apps`](https://github.com/openai/plugins/tree/main/plugins/build-macos-apps) (MIT). They cover building, running, and debugging macOS apps with shell-first Xcode and Swift workflows, SwiftUI and AppKit patterns, Liquid Glass, telemetry, test triage, signing, and notarization. -| Source (Codex plugin) | This bundle | -| -------------------------------- | ------------------------------------------------- | -| `.codex-plugin/plugin.json` | Not needed — flat `.agents/` layout, no manifest. | -| `agents/openai.yaml` | Skipped — Codex-surface-specific agent metadata, no analog elsewhere. | -| `skills//SKILL.md` | Copied 1:1 (frontmatter is already compatible). | -| `skills//references/*` | Copied 1:1. | -| `commands/.md` | Re-shaped as `skills//SKILL.md` with `disable-model-invocation: true`. | -| `assets/` (icon, svg) | Skipped — no manifest references them. | -| `.codex/environments/environment.toml` wiring | Stripped — that wired up Codex's project Run button, which doesn't exist in Cursor or Claude Code. The `script/build_and_run.sh` entrypoint stayed; the env file did not. | +- `macos-appkit-interop/` +- `macos-build-run-debug/` +- `macos-liquid-glass/` +- `macos-packaging-notarization/` +- `macos-signing-entitlements/` +- `macos-swiftpm/` +- `macos-swiftui-patterns/` +- `macos-telemetry/` +- `macos-test-triage/` +- `macos-view-refactor/` +- `macos-window-management/` -### Run entrypoint +Three of the plugin's slash commands are kept as explicit-invoke skills (`disable-model-invocation: true`): -The `build-run-debug` and `build-and-run-macos-app` skills create a project-local -`script/build_and_run.sh` as the single kill + build + run entrypoint. Invoke -it directly from a terminal. If you want a one-click Run, wrap it in your -editor's task system (`.vscode/tasks.json`, an Xcode scheme run action, a -`Makefile` target, etc.). +- `build-and-run-macos-app/` +- `fix-codesign-error/` +- `test-macos-app/` -## Scope - -Inherited from the source plugin — these skills cover: - -- discovering local Xcode workspaces, projects, and Swift packages -- building/running macOS apps with shell-first Xcode/Swift workflows -- one project-local `script/build_and_run.sh` entrypoint -- native macOS SwiftUI scenes, menus, settings, toolbars, multiwindow flows -- modern Liquid Glass design-system patterns -- bridging into AppKit for representables, responder-chain, panels -- refactoring large macOS view files -- lightweight `os.Logger` instrumentation + `log stream` verification -- triaging failing unit / integration / UI-hosted macOS tests -- signing, entitlements, hardened runtime, Gatekeeper diagnosis -- packaging and notarization prep - -Not covered: iOS / watchOS / tvOS, desktop UI automation, App Store Connect -releases, pixel-perfect visual design. - -## Source attribution - -- Original Codex plugin: -- Upstream author: OpenAI (`support@openai.com`) -- License: MIT (inherited from the source plugin) +The plugin's manifest, Codex agent metadata, assets, and environment wiring were not carried over. `script/build_and_run.sh` is the build and run entrypoint the skills refer to. diff --git a/.agents/skills/appkit-interop/SKILL.md b/.agents/skills/appkit-interop/SKILL.md deleted file mode 100644 index 68e8cb4f3..000000000 --- a/.agents/skills/appkit-interop/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: appkit-interop -description: Decide when and how to bridge a macOS app from SwiftUI into AppKit. Use when implementing NSViewRepresentable or NSViewControllerRepresentable, accessing NSWindow or the responder chain, presenting panels, customizing menus, or handling desktop behaviors that SwiftUI does not model cleanly. ---- - -# AppKit Interop - -## Quick Start - -Use this skill when SwiftUI is close but not quite enough for native macOS behavior. -Keep the bridge as small and explicit as possible. SwiftUI should usually remain -the source of truth, while AppKit handles the imperative edge. - -## Choose The Smallest Bridge - -- Use pure SwiftUI when the required behavior already exists in scenes, toolbars, commands, inspectors, or standard controls. -- Use `NSViewRepresentable` when you need a specific AppKit view with lightweight lifecycle needs. -- Use `NSViewControllerRepresentable` when you need controller lifecycle, delegation, or presentation coordination. -- Use direct AppKit window or app hooks when you need `NSWindow`, responder-chain, menu validation, panels, or app-level behavior. - -## Workflow - -1. Name the capability gap precisely. - - Window behavior - - Text system behavior - - Menu validation - - Drag and drop - - File open/save panels - - First responder control - -2. Pick the smallest boundary that solves it. - - Avoid porting a whole screen to AppKit when one wrapped control or coordinator would do. - -3. Keep ownership explicit. - - SwiftUI owns value state, selection, and observable models. - - AppKit objects stay inside the representable, coordinator, or bridge object. - -4. Expose a narrow interface back to SwiftUI. - - Bindings for editable state - - Small callbacks for events - - Focused bridge services only when necessary - -5. Validate lifecycle assumptions. - - SwiftUI may recreate representables. - - Coordinators exist to hold delegate and target-action glue, not as a second app architecture. - -## References - -- `references/representables.md`: choosing between view and view-controller wrappers, plus coordinator patterns. -- `references/window-panels.md`: window access, utility windows, and open/save panels. -- `references/responder-menus.md`: first responder, command routing, and menu validation. -- `references/drag-drop-pasteboard.md`: pasteboard, file URLs, and desktop drag/drop edges. - -## Guardrails - -- Do not duplicate the source of truth between SwiftUI and AppKit. -- Do not let `Coordinator` become an unstructured dumping ground. -- Do not store long-lived `NSView` or `NSWindow` instances globally without a strong ownership reason. -- Prefer a tiny tested bridge over rewriting the feature in raw AppKit. -- If a pattern can remain entirely in `swiftui-patterns`, keep it there. - -## Output Expectations - -Provide: -- the exact SwiftUI limitation being crossed -- the smallest recommended bridge type -- the data-flow boundary between SwiftUI and AppKit -- the lifecycle or validation risks to watch diff --git a/.agents/skills/appkit-interop/references/drag-drop-pasteboard.md b/.agents/skills/appkit-interop/references/drag-drop-pasteboard.md deleted file mode 100644 index d1f5e73c0..000000000 --- a/.agents/skills/appkit-interop/references/drag-drop-pasteboard.md +++ /dev/null @@ -1,23 +0,0 @@ -# Drag, Drop, and Pasteboard - -## Intent - -Use this when desktop drag/drop or pasteboard behavior exceeds what plain SwiftUI modifiers cover comfortably. - -## Good fits - -- File URL dragging -- Pasteboard interoperability with other macOS apps -- Rich drag previews or AppKit-specific drop validation -- Legacy AppKit views with custom drag types - -## Core patterns - -- Start with SwiftUI drag/drop APIs when they already cover the use case. -- Drop to AppKit when you need `NSPasteboard`, custom pasteboard types, or older AppKit delegate flows. -- Keep data conversion at the boundary instead of leaking AppKit types through the whole feature. - -## Pitfalls - -- Do not move your whole list or canvas into AppKit just for one drop target. -- Keep file and pasteboard types explicit and validated. diff --git a/.agents/skills/appkit-interop/references/representables.md b/.agents/skills/appkit-interop/references/representables.md deleted file mode 100644 index 104eea354..000000000 --- a/.agents/skills/appkit-interop/references/representables.md +++ /dev/null @@ -1,56 +0,0 @@ -# Representables - -## Intent - -Use this when wrapping an AppKit control or controller for a SwiftUI macOS app. - -## Choose the wrapper type - -- Use `NSViewRepresentable` for a view-level bridge such as `NSTextView`, `NSScrollView`, or a custom AppKit control. -- Use `NSViewControllerRepresentable` when you need controller lifecycle, delegate coordination, or AppKit presentation logic. - -## Skeleton - -```swift -struct LegacyTextView: NSViewRepresentable { - @Binding var text: String - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - let textView = NSTextView() - textView.delegate = context.coordinator - scrollView.documentView = textView - return scrollView - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - guard let textView = nsView.documentView as? NSTextView else { return } - if textView.string != text { - textView.string = text - } - } - - final class Coordinator: NSObject, NSTextViewDelegate { - @Binding var text: String - - init(text: Binding) { - _text = text - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - text = textView.string - } - } -} -``` - -## Pitfalls - -- Avoid infinite update loops by only pushing state into AppKit when values actually changed. -- Keep delegates and target-action wiring in the coordinator. -- If the wrapper grows into a full screen, re-evaluate the boundary. diff --git a/.agents/skills/appkit-interop/references/responder-menus.md b/.agents/skills/appkit-interop/references/responder-menus.md deleted file mode 100644 index 9c312d72a..000000000 --- a/.agents/skills/appkit-interop/references/responder-menus.md +++ /dev/null @@ -1,22 +0,0 @@ -# Responder Chain and Menus - -## Intent - -Use this when command handling depends on the active window, first responder, or AppKit menu validation. - -## Core patterns - -- Start with SwiftUI `commands`, `FocusedValue`, and focused scene state. -- Use AppKit responder-chain hooks only when command routing or validation truly depends on the underlying responder system. -- Keep menu enablement rules close to the state they depend on. - -## Good fits for AppKit - -- Validating whether a menu item should be enabled -- Routing actions through the current first responder -- Integrating with existing AppKit document or text behaviors - -## Pitfalls - -- Do not recreate AppKit-style global command handling when SwiftUI focused values would work. -- Avoid scattering command logic between SwiftUI closures and AppKit selectors without a clear boundary. diff --git a/.agents/skills/appkit-interop/references/window-panels.md b/.agents/skills/appkit-interop/references/window-panels.md deleted file mode 100644 index f89e0f397..000000000 --- a/.agents/skills/appkit-interop/references/window-panels.md +++ /dev/null @@ -1,37 +0,0 @@ -# Windows and Panels - -## Intent - -Use this when SwiftUI scenes are not enough for the required macOS window or panel behavior. - -## Common cases - -- Accessing the backing `NSWindow` -- Configuring titlebar or toolbar behavior -- Presenting `NSOpenPanel` or `NSSavePanel` -- Managing utility panels or floating windows - -## Core patterns - -- Prefer SwiftUI `Window`, `WindowGroup`, and `openWindow` first. -- Use AppKit only for window features SwiftUI does not expose cleanly. -- Keep file open/save panels behind a small service or helper instead of scattering panel setup throughout the view tree. - -## Example: open panel - -```swift -@MainActor -func chooseFile() -> URL? { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - return panel.runModal() == .OK ? panel.url : nil -} -``` - -## Pitfalls - -- Do not let random views own long-lived `NSWindow` references. -- Keep floating panels and utility windows consistent with the scene model. -- If the behavior is really just settings or a secondary scene, go back to `swiftui-patterns`. diff --git a/.agents/skills/build-and-run-macos-app/SKILL.md b/.agents/skills/build-and-run-macos-app/SKILL.md index d0a399d66..9828cd728 100644 --- a/.agents/skills/build-and-run-macos-app/SKILL.md +++ b/.agents/skills/build-and-run-macos-app/SKILL.md @@ -25,7 +25,7 @@ that script as the default build/run entrypoint. 3. Create or update `script/build_and_run.sh` so it always stops the current app, builds the macOS target, and launches the fresh result. 4. For SwiftPM, keep raw executable launch only for true CLI tools; for AppKit/SwiftUI GUI apps, create a project-local `.app` bundle and launch it with `/usr/bin/open -n`. 5. Support optional script flags for `--debug`, `--logs`, `--telemetry`, and `--verify`. -6. Follow the canonical bootstrap contract in `../build-run-debug/references/build-script.md` for the exact script shape. +6. Follow the canonical bootstrap contract in `../macos-build-run-debug/references/build-run-script.md` for the exact script shape. 7. Run the script in the requested mode and summarize any build, script, or launch failure. ## Guardrails diff --git a/.agents/skills/build-run-debug/SKILL.md b/.agents/skills/build-run-debug/SKILL.md deleted file mode 100644 index 4cf2b9be5..000000000 --- a/.agents/skills/build-run-debug/SKILL.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: build-run-debug -description: Build, run, and debug local macOS apps and desktop executables using shell-first Xcode and Swift workflows. Use when asked to build a Mac app, launch it, diagnose compiler or linker failures, inspect startup problems, or debug desktop-only runtime issues. ---- - -# Build / Run / Debug - -## Quick Start - -Use this skill to set up one project-local `script/build_and_run.sh` entrypoint, -then use that script as the default build/run path. - -Prefer shell-first workflows: - -- `./script/build_and_run.sh` as the single kill + build + run entrypoint once it exists -- `xcodebuild` for Xcode workspaces or projects -- `swift build` plus raw executable launch inside that script for true SwiftPM command-line tools -- `swift build` plus project-local `.app` bundle staging and `/usr/bin/open -n` launch for SwiftPM AppKit/SwiftUI GUI apps -- optional script flags for `lldb`, `log stream`, telemetry verification, or post-launch process checks - -Do not assume simulators, touch interaction, or mobile-specific tooling. - -If an Xcode-aware MCP surface is already available and the user explicitly wants -it, use it only where it fits. Keep that usage narrow and honest: prefer it for -Xcode-oriented discovery, logging, or debugging support, and do not force -simulator-specific workflows onto pure macOS tasks. - -## Workflow - -1. Discover the project shape. - - Check whether the workspace is already inside a git repo with `git rev-parse --is-inside-work-tree`. - - If no git repo is present, run `git init` at the project/workspace root before building so git-backed editor features unlock. Never run `git init` inside a nested subdirectory when the current workspace already belongs to a parent repo. - - Look for `.xcworkspace`, `.xcodeproj`, and `Package.swift`. - - If more than one candidate exists, explain the default choice and the ambiguity. - -2. Resolve the runnable target and process name. - - For Xcode, list schemes and prefer the app-producing scheme unless the user names another one. - - For SwiftPM, identify executable products when possible. - - Split SwiftPM launch handling by product type: - - use raw executable launch only for true command-line tools, - - use a generated project-local `.app` bundle for AppKit/SwiftUI GUI apps. - - Determine the app/process name to kill before relaunching. - -3. Create or update `script/build_and_run.sh`. - - Make the script project-specific and executable. - - It should always: - 1. stop the existing running app/process if present, - 2. build the macOS target, - 3. launch the freshly built app or executable. - - Add optional flags for debugging/log inspection: - - `--debug` to launch under `lldb` or attach the debugger - - `--logs` to stream process logs after launch - - `--telemetry` to stream unified logs filtered to the app subsystem/category - - `--verify` to launch the app and confirm the process exists with `pgrep -x ` - - Keep the default no-flag path simple: kill, build, run. - - Prefer writing one script that owns this workflow instead of repeatedly asking the agent to manually run `swift build`, locate the artifact, then invoke an ad hoc run command. - - For SwiftPM GUI apps, make the script build the product, create `dist/.app`, copy the binary to `Contents/MacOS/`, generate a minimal `Contents/Info.plist` with `CFBundlePackageType=APPL`, `CFBundleExecutable`, `CFBundleIdentifier`, `CFBundleName`, `LSMinimumSystemVersion`, and `NSPrincipalClass=NSApplication`, then launch with `/usr/bin/open -n `. - - For SwiftPM GUI `--logs` and `--telemetry`, launch the bundle with `/usr/bin/open -n` first, then stream unified logs with `/usr/bin/log stream --info ...`. - - Do not recommend direct SwiftPM executable launch for AppKit/SwiftUI GUI apps. - - Use `references/build-script.md` as the canonical source for the script shape. Do not fork a second authoritative snippet in another skill or command. - - Keep the run script outside app source. It belongs in `script/build_and_run.sh`, not in `App/`, `Views/`, `Models/`, `Stores/`, `Services/`, or `Support/`. - -4. Build and run through the script. - - Default to `./script/build_and_run.sh`. - - Use `./script/build_and_run.sh --debug`, `--logs`, `--telemetry`, or `--verify` when the user asks for debugger/log/telemetry/process verification support. - -5. Summarize failures correctly. - - Classify the blocker as compiler, linker, signing, build settings, missing SDK/toolchain, script bug, or runtime launch. - - Quote the smallest useful error snippet and explain what it means. - -6. Debug the right way. - - Use the script's `--logs` or `--telemetry` mode for config, entitlement, sandbox, and action-event verification. - - For SwiftPM GUI apps, if the app bundle launches but its window still does not come forward, check whether the entrypoint needs `NSApp.setActivationPolicy(.regular)` and `NSApp.activate(ignoringOtherApps: true)`. - - Use the script's `--debug` mode or direct `lldb` if symbolized crash debugging is needed. - - If the user needs to instrument and verify specific window, sidebar, menu, or menu bar actions, switch to `telemetry`. - - Keep evidence tight and user-facing. - -7. Use Xcode-aware MCP tooling only when it helps. - - If the user explicitly asks for XcodeBuildMCP and it is already available, prefer it over ad hoc setup. - - Use the MCP for Xcode-aware discovery or debug/logging workflows when the available tool surface clearly matches the task. - - Fall back to shell commands immediately when the MCP does not provide a clean macOS path. - -## Preferred Commands - -- Project discovery: - - `find . -name '*.xcworkspace' -o -name '*.xcodeproj' -o -name 'Package.swift'` -- Scheme discovery: - - `xcodebuild -list -workspace ` - - `xcodebuild -list -project ` -- Build/run: - - `./script/build_and_run.sh` - - `./script/build_and_run.sh --debug` - - `./script/build_and_run.sh --logs` - - `./script/build_and_run.sh --telemetry` - - `./script/build_and_run.sh --verify` - -## References - -- `references/build-script.md`: canonical `script/build_and_run.sh` shapes for SwiftPM CLI tools and SwiftPM AppKit/SwiftUI GUI apps. - -## Guardrails - -- Prefer the narrowest command that proves or disproves the current theory. -- Do not leave the user with a one-off manual command chain once a stable `build_and_run.sh` script can own the workflow. -- Do not launch a SwiftUI/AppKit SwiftPM GUI app as a raw executable unless the user explicitly wants to diagnose that failure mode: it can produce no Dock icon, no foreground activation, and missing bundle identifier warnings. Keep raw executable launch only for true command-line tools. -- Do not claim UI state you cannot inspect directly. -- Do not describe mobile or simulator workflows as if they apply to macOS. -- If build output is huge, summarize the first real blocker and point to follow-up commands. - -## Output Expectations - -Provide: -- the detected project type -- the script path you configured, if applicable -- the command you ran -- whether build and launch succeeded -- the top blocker if they failed -- the smallest sensible next action diff --git a/.agents/skills/build-run-debug/references/build-script.md b/.agents/skills/build-run-debug/references/build-script.md deleted file mode 100644 index 3fbfa6c3e..000000000 --- a/.agents/skills/build-run-debug/references/build-script.md +++ /dev/null @@ -1,155 +0,0 @@ -# Build Script - -Canonical shape for the macOS build plugin's local `script/build_and_run.sh`. - -When a project does not already have an established macOS run entrypoint: - -1. Create one project-local `script/build_and_run.sh`. -2. Make it executable. -3. Use it as the single kill + build + run entrypoint. -4. Support optional `--debug`, `--logs`, `--telemetry`, and `--verify` flags. - -## `script/build_and_run.sh` - -Use one project-specific script with a tiny mode switch and a default no-flag -path that just kills, builds, and launches. Keep raw executable launch only for -true command-line tools. For SwiftPM AppKit/SwiftUI GUI apps, stage a -project-local `.app` bundle and launch that bundle with `/usr/bin/open -n`. - -### SwiftPM CLI executable - -Use this shape for true command-line tools: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -MODE="${1:-run}" -APP_NAME="MyTool" - -pkill -x "$APP_NAME" >/dev/null 2>&1 || true - -swift build -APP_BINARY="$(swift build --show-bin-path)/$APP_NAME" - -case "$MODE" in - run) - "$APP_BINARY" - ;; - --debug|debug) - lldb -- "$APP_BINARY" - ;; - --logs|logs) - "$APP_BINARY" & - /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" - ;; - --telemetry|telemetry) - "$APP_BINARY" & - /usr/bin/log stream --info --style compact --predicate "subsystem == \"com.example.MyTool\"" - ;; - --verify|verify) - "$APP_BINARY" & - sleep 1 - pgrep -x "$APP_NAME" >/dev/null - ;; - *) - echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 - exit 2 - ;; -esac -``` - -### SwiftPM AppKit/SwiftUI GUI app - -Use this shape for SwiftPM GUI apps so they launch as a real foreground app -bundle with Dock activation and bundle metadata: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -MODE="${1:-run}" -APP_NAME="MyApp" -BUNDLE_ID="com.example.MyApp" -MIN_SYSTEM_VERSION="14.0" - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -DIST_DIR="$ROOT_DIR/dist" -APP_BUNDLE="$DIST_DIR/$APP_NAME.app" -APP_CONTENTS="$APP_BUNDLE/Contents" -APP_MACOS="$APP_CONTENTS/MacOS" -APP_BINARY="$APP_MACOS/$APP_NAME" -INFO_PLIST="$APP_CONTENTS/Info.plist" - -pkill -x "$APP_NAME" >/dev/null 2>&1 || true - -swift build -BUILD_BINARY="$(swift build --show-bin-path)/$APP_NAME" - -rm -rf "$APP_BUNDLE" -mkdir -p "$APP_MACOS" -cp "$BUILD_BINARY" "$APP_BINARY" -chmod +x "$APP_BINARY" - -cat >"$INFO_PLIST" < - - - - CFBundleExecutable - $APP_NAME - CFBundleIdentifier - $BUNDLE_ID - CFBundleName - $APP_NAME - CFBundlePackageType - APPL - LSMinimumSystemVersion - $MIN_SYSTEM_VERSION - NSPrincipalClass - NSApplication - - -PLIST - -open_app() { - /usr/bin/open -n "$APP_BUNDLE" -} - -case "$MODE" in - run) - open_app - ;; - --debug|debug) - lldb -- "$APP_BINARY" - ;; - --logs|logs) - open_app - /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" - ;; - --telemetry|telemetry) - open_app - /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\"" - ;; - --verify|verify) - open_app - sleep 1 - pgrep -x "$APP_NAME" >/dev/null - ;; - *) - echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 - exit 2 - ;; -esac -``` - -Launching a SwiftPM GUI binary directly can produce no Dock icon, no foreground -activation, and missing bundle identifier warnings. If the `.app` bundle opens -but the main window still does not come forward, the app entrypoint may need -`NSApp.setActivationPolicy(.regular)` and -`NSApp.activate(ignoringOtherApps: true)`. - -Adapt the build step for Xcode projects by replacing `swift build` with -`xcodebuild -project ...` or `xcodebuild -workspace ...`, then launch the built -`.app` binary from DerivedData or a deterministic project-local build path. Keep -the one-script interface and mode flags the same. diff --git a/.agents/skills/liquid-glass/SKILL.md b/.agents/skills/liquid-glass/SKILL.md deleted file mode 100644 index 8f33d888c..000000000 --- a/.agents/skills/liquid-glass/SKILL.md +++ /dev/null @@ -1,170 +0,0 @@ ---- -name: liquid-glass -description: Implement, refactor, or review modern macOS SwiftUI UI for the new design system and Liquid Glass. Use when adopting Liquid Glass, updating NavigationSplitView, toolbars, search, sheets, and controls, removing custom backgrounds that fight system materials, or building custom glass surfaces with glassEffect, GlassEffectContainer, and glassEffectID. ---- - -# Liquid Glass - -## Overview - -Use this skill to bring a macOS SwiftUI app into the modern macOS design system -with the least custom chrome possible. Start with standard app structure, -toolbars, search placement, sheets, and controls, then add custom Liquid Glass -only where the app needs a distinctive surface. - -Prefer system-provided glass and adaptive materials over bespoke blur, opaque -backgrounds, or custom toolbar/sidebar skins. Audit existing UI for extra fills, -scrims, and clipping before adding more effects. - -## Workflow - -1. Read the relevant scene or root view and identify the structural pattern: - `NavigationSplitView`, `TabView`, sheet presentation, detail/inspector - layout, toolbar, or custom floating controls. -2. Remove custom backgrounds or darkening layers behind system sheets, - sidebars, and toolbars unless the product explicitly needs them. These can - obscure Liquid Glass and interfere with the automatic scroll-edge effect. -3. Update standard SwiftUI structure and controls first. -4. Add custom `glassEffect` surfaces only for app-specific UI that standard - controls do not cover. -5. Validate that glass grouping, transitions, icon treatment, and foreground - activation are visually coherent and still usable with pointer and keyboard. -6. If the UI change also affects launch behavior for a SwiftPM GUI app, use - `build-run-debug` so the app runs as a foreground `.app` bundle rather - than as a raw executable. - -## App Structure - -- Prefer `NavigationSplitView` for hierarchy-driven macOS layouts. Let the - sidebar use the system Liquid Glass material instead of painting over it. -- For hero artwork or large media adjacent to a floating sidebar, use - `backgroundExtensionEffect` so the visual can extend beyond the safe area - without clipping the subject. -- Keep inspectors visually associated with the current selection and avoid - giving them a heavier custom background than the content they inspect. -- If the app uses tabs, keep `TabView` for persistent top-level sections and - preserve each tab's local navigation state. -- Do not force iPhone-only tab bar minimize/accessory behavior onto a Mac app. - On macOS, prefer a conventional top toolbar and native tab/search placement. -- If a sheet already uses `presentationBackground` purely to imitate frosted - material, consider removing it and letting the system's new material render. -- For sheet transitions that should visually originate from a toolbar button, - make the presenting item the source of a navigation zoom transition and mark - the sheet content as the destination. - -## Toolbars - -- Assume toolbar items are rendered on a floating Liquid Glass surface and are - grouped automatically. -- Use `ToolbarSpacer` to communicate grouping: - - fixed spacing to split related actions into a distinct group, - - flexible spacing to push a leading action away from a trailing group. -- Use `sharedBackgroundVisibility` when an item should stand alone without the - shared glass background, for example a profile/avatar item. -- Add `badge` to toolbar item content for notification or status indicators. -- Expect monochrome icon rendering in more toolbar contexts. Use `tint` only to - convey semantic meaning such as a primary action or alert state, not as pure - decoration. -- If content underneath a toolbar has extra darkening, blur, or custom - background layers, remove them before judging the new automatic scroll-edge - effect. -- For dense windows with many floating elements, tune the content's scroll-edge - treatment with `scrollEdgeEffectStyle` instead of building a custom bar - background. - -## Search - -- For a search field that applies across a whole split-view hierarchy, attach - `searchable` to the `NavigationSplitView`, not to just one column. -- When search is secondary and a compact affordance is better, use - `searchToolbarBehavior` instead of hand-rolling a toolbar button and a - separate field. -- For a dedicated search page in a multi-tab app, assign the search role to one - tab and place `searchable` on the `TabView`. -- Make most of the app's content discoverable from search when the field lives - in the top-trailing toolbar location. -- On iPad and Mac, expect the dedicated search tab to show a centered field - above browsing suggestions rather than a bottom search bar. - -## Controls - -- Prefer standard SwiftUI controls before creating custom glass components. -- Expect bordered buttons to default to a capsule shape at larger sizes. On - macOS, mini/small/medium controls preserve a rounded-rectangle shape for - denser layouts. -- Use `buttonBorderShape` when a button shape needs to be explicit. -- Use `controlSize` to preserve density in inspectors and popovers, and reserve - extra-large sizing for truly prominent actions. -- Use the system glass and glass-prominent button styles for primary actions - instead of recreating a translucent button background by hand. -- For sliders with discrete values, pass `step` to get automatic tick marks or - provide specific ticks in a `ticks` closure. -- For sliders that should expand left and right around a baseline, set - `neutralValue`. -- Use `Label` or standard control initializers for menu items so icons are - consistently placed on the leading edge across platforms. -- For custom shapes that must align concentrically with a sheet, card, or - window corner, use a concentric rectangle shape with the - `containerConcentric` corner configuration instead of guessing a radius. - -## Custom Liquid Glass - -- Use `glassEffect` for custom glass surfaces. The default shape is capsule-like - and text foregrounds are automatically made vibrant and legible against - changing content underneath. -- Pass an explicit shape to `glassEffect` when a capsule is not the right fit. -- Add `tint` only when color carries meaning, such as a status or call to - action. -- Use `glassEffect(... .interactive())` for custom controls or containers with - interactive elements so they scale, bounce, and shimmer like system glass. -- Wrap nearby custom glass elements in one `GlassEffectContainer`. This is a - visual correctness rule, not just organization: separate containers cannot - sample each other's glass and can produce inconsistent refraction. -- Use `glassEffectID` with a local `@Namespace` when matching glass elements - should morph between collapsed and expanded states. - -## Review Checklist - -- Standard structures and controls were updated first before adding custom - glass. -- Opaque backgrounds, dark scrims, and custom toolbar/sheet fills that fight the - system material were removed unless intentionally required. -- `searchable` is attached at the correct container level for the intended - search scope. -- Toolbar grouping uses `ToolbarSpacer`, `sharedBackgroundVisibility`, and - `badge` instead of one-off hand-built chrome. -- Icon tint is semantic, not decorative. -- Custom glass elements that sit near each other share a - `GlassEffectContainer`. -- Morphing glass transitions use `glassEffectID` with a namespace and stable - identity. -- Any SwiftPM GUI app used to test the result is launched as a `.app` bundle, - not as a raw executable. - -## Guardrails - -- Do not rebuild system sidebars, toolbars, sheets, or controls from scratch if - standard SwiftUI APIs already provide the modern macOS behavior. -- Do not apply custom opaque backgrounds behind a `NavigationSplitView` - sidebar, system toolbar, or sheet just because an older version needed - one. -- Do not scatter related glass elements across multiple - `GlassEffectContainer`s. -- Do not tint every icon or glass surface for visual variety alone. -- Do not assume an iPhone tab/search behavior is the right answer on macOS. - Prefer desktop-native toolbar, split-view, and inspector placement. -- Do not leave a GUI SwiftPM app launching as a bare executable when reviewing - Liquid Glass behavior; missing foreground activation can make a design bug - look like a rendering bug. - -## When To Use Other Skills - -- Use `swiftui-patterns` when the main question is scene architecture, - sidebar/detail layout, commands, or settings rather than Liquid Glass-specific - treatment. -- Use `view-refactor` when the main issue is file structure, state - ownership, and extracting large views before design changes. -- Use `appkit-interop` when the design requires window, panel, responder-chain, - or AppKit-only control behavior. -- Use `build-run-debug` when you need to launch, verify, or inspect logs - for the app after the visual update. diff --git a/.agents/skills/packaging-notarization/SKILL.md b/.agents/skills/packaging-notarization/SKILL.md deleted file mode 100644 index 47fc1431b..000000000 --- a/.agents/skills/packaging-notarization/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: packaging-notarization -description: Prepare and troubleshoot packaging, signing, and notarization workflows for macOS distribution. Use when asked to archive a Mac app, validate bundle structure, reason about notarization readiness, or explain distribution-only failures. ---- - -# Packaging & Notarization - -## Quick Start - -Use this skill when the work is about shipping the app rather than merely -running it locally: archives, exported app bundles, notarization readiness, -hardened runtime, or distribution validation. - -## Workflow - -1. Confirm the distribution goal. - - Local archive validation - - Signed distributable app - - Notarization troubleshooting - -2. Inspect the artifact. - - Validate app bundle structure. - - Check nested frameworks, helper tools, and entitlements. - -3. Inspect signing and runtime prerequisites. - - Hardened runtime - - Signing identity - - Nested code signatures - - Required entitlements - -4. Explain notarization readiness or failure. - - Separate packaging issues from trust-policy symptoms. - - Point to the minimum follow-up validation commands. - -## Guardrails - -- Do not present notarization as required for ordinary local debug runs. -- Call out when you lack the actual exported artifact and are inferring from project settings. -- Keep advice concrete and verifiable. - -## Output Expectations - -Provide: -- what artifact or settings were inspected -- whether the app looks distribution-ready -- the top missing prerequisite or failure mode -- the next validation or repair step diff --git a/.agents/skills/pricing-update/SKILL.md b/.agents/skills/pricing-update/SKILL.md index 3b0bd84e9..e64cb8331 100644 --- a/.agents/skills/pricing-update/SKILL.md +++ b/.agents/skills/pricing-update/SKILL.md @@ -5,20 +5,20 @@ description: Sync Runway's pricing supplement with Cursor's published model pric # Pricing Update -`Sources/Runway/Resources/pricing_supplement.json` prices the models no public catalog carries (Cursor-native models like `auto`, `composer-*`, `github_bugbot`), supplies fast-variant multipliers, and maps provider log/CSV slugs to canonical pricing keys. On merge to `main`, `.github/workflows/pricing-supplement.yml` validates it and publishes it to GitHub Pages; installed apps pick it up within about an hour — no release needed. Full background: `docs/pricing.md`. +`Sources/Runway/Resources/pricing_supplement.json` prices the models no public catalog carries (Cursor-native models like `auto`, `composer-*`, `github_bugbot`), supplies fast-variant multipliers, and maps provider log and CSV slugs to canonical pricing keys. On merge to `main`, `.github/workflows/pricing-supplement.yml` validates it and publishes it to GitHub Pages. Installed apps pick it up within about an hour. No release needed. Background: `docs/pricing.md`. -Only the supplement needs manual care. Normal API models (new Claude/GPT/Gemini/Grok releases) are priced automatically by the daily LiteLLM and models.dev fetches — do not add them to the supplement unless they need an alias rule or the catalogs are wrong. +Only the supplement needs manual care. Normal API models (new Claude, GPT, Gemini, Grok releases) are priced by the hourly LiteLLM and models.dev fetches. Do not add them to the supplement unless they need an alias rule or the catalogs are wrong. ## Steps -### 1. Pull the source of truth into context +### 1. Pull the source of truth -Fetch https://cursor.com/docs/models-and-pricing.md and read the whole thing. This is the canonical source for: +Fetch https://cursor.com/docs/models-and-pricing.md and read the whole thing. It is the source for: -- Cursor-native model prices (`auto`, `composer-*`, Bugbot) — input, cache write, cache read, output, all USD per million tokens. -- Which models have a fast variant and what the fast pricing is. -- Long-context tiers (e.g. Sonnet 1M) that bill the whole request at the >200k rate. -- Model names/slugs as Cursor spells them (needed for alias rules). +- Cursor-native model prices (`auto`, `composer-*`, Bugbot): input, cache write, cache read, output, all USD per million tokens. +- Which models have a fast variant and its pricing. +- Long-context tiers (for example Sonnet 1M) that bill the whole request at the >200k rate. +- Model names and slugs as Cursor spells them (needed for alias rules). ### 2. Diff against the current supplement @@ -26,17 +26,17 @@ Read `Sources/Runway/Resources/pricing_supplement.json` and compare: - **Price changes** on existing `pricing` entries. - **New Cursor-native models** missing from `pricing`. -- **Removed/renamed models** — never delete an entry that old usage data may still reference; keep it so historical days keep their dollars. Only remove an entry if it was outright wrong. -- **`fast_multipliers`** — for API models whose fast variant is priced as a multiplier of the base rate. Only needed when the catalogs don't carry a `-fast` key themselves. -- **`alias_rules`** — a new model usually needs one, because Cursor CSV slugs and Codex/Claude log names rarely match catalog keys exactly (thinking suffixes, effort levels like `-low`/`-high`/`-xhigh`, dot vs dash versions). Follow the existing patterns: anchored regex, escaped dots, optional effort/thinking groups. `-fast` variants need their own rule ordered BEFORE the base rule (first match wins). +- **Removed or renamed models.** Never delete an entry that old usage data may still reference. Keep it so historical days keep their dollars. Only remove an entry if it was wrong. +- **`fast_multipliers`**: for API models whose fast variant is priced as a multiplier of the base rate. Only needed when the catalogs do not carry a `-fast` key themselves. +- **`alias_rules`**: a new model usually needs one, because Cursor CSV slugs and Codex and Claude log names rarely match catalog keys exactly (thinking suffixes, effort levels like `-low`, `-high`, `-xhigh`, dot vs dash versions). Follow the existing patterns: anchored regex, escaped dots, optional effort and thinking groups. `-fast` variants need their own rule ordered before the base rule (first match wins). Where a model exists in LiteLLM or models.dev, prefer an alias to that canonical key over duplicating prices in the supplement. ### 3. Edit the supplement -- Update `updated_at` to today (YYYY-MM-DD). If a supplement change already shipped earlier the same day, use a full ISO timestamp (`YYYY-MM-DDTHH:MM:SSZ`) instead — equal values keep serving the cache, so a bare date can't distinguish same-day revisions. +- Update `updated_at` to today (YYYY-MM-DD). If a supplement change already shipped earlier the same day, use a full ISO timestamp (`YYYY-MM-DDTHH:MM:SSZ`) instead. Equal values keep serving the cache, so a bare date cannot distinguish same-day revisions. - Keep the file's style: 2-space indent, rates as plain numbers, `$comment` explanations for non-obvious entries. -- Rates are USD per million tokens; cache read defaults matter — copy the exact numbers from the Cursor page, don't infer. +- Rates are USD per million tokens. Copy the exact numbers from the Cursor page. Do not infer. ### 4. Validate @@ -69,7 +69,7 @@ If a new alias rule maps a slug that appears in real usage, add a resolution tes ### 5. Open a PR -Branch from `main`, commit (`fix(pricing): ...` or `feat(pricing): ...`), and open a PR following the repo's PR description structure (TL;DR / What was happening / What this changes). Cite the Cursor doc as the source and list each price or alias change explicitly so the owner can verify numbers at a glance. Never push pricing changes directly to `main`. +Branch from `main`, commit (`fix(pricing): ...` or `feat(pricing): ...`), and open a PR with the repo's PR description structure (TL;DR / What was happening / What this changes). Cite the Cursor doc as the source and list each price or alias change so the owner can verify the numbers. Never push pricing changes directly to `main`. ### 6. Verify publication after merge @@ -80,16 +80,16 @@ gh run list --workflow=pricing-supplement.yml --limit 1 curl -s https://mstallone.github.io/runway/pricing_supplement.json | python3 -c "import json,sys; print(json.load(sys.stdin)['updated_at'])" ``` -The `updated_at` served must match the merged file. Publishing is two hops: the supplement workflow pushes the file to the `update-feed` branch, then `.github/workflows/deploy-update-feed.yml` on `main` deploys that branch to the live site (Pages source is "GitHub Actions"). If the URL is stale after ~10 minutes, check `gh run list --workflow=deploy-update-feed.yml` and re-run **`gh workflow run deploy-update-feed.yml --ref main`** (not `--ref update-feed`). +The served `updated_at` must match the merged file. Publishing is two hops: the supplement workflow pushes the file to the `update-feed` branch, then `.github/workflows/deploy-update-feed.yml` on `main` deploys that branch to the live site (Pages source is "GitHub Actions"). If the URL is stale after about 10 minutes, check `gh run list --workflow=deploy-update-feed.yml` and re-run `gh workflow run deploy-update-feed.yml --ref main` (not `--ref update-feed`). ## Optional: refresh bundled snapshots -The bundled LiteLLM/models.dev snapshots (`pricing_*_snapshot.json`) are a separate concern — offline fallbacks refreshed with `./script/update_pricing_snapshots.sh`, typically before a release. Staleness is harmless; only regenerate them if asked or as part of release prep, in their own commit. +The bundled LiteLLM and models.dev snapshots (`pricing_*_snapshot.json`) are offline fallbacks refreshed with `./script/update_pricing_snapshots.sh`, typically before a release. Staleness is harmless. Only regenerate them if asked or as part of release prep, in their own commit. ## Rules -- USD per million tokens everywhere; exact numbers from the Cursor page, never inferred. -- Prefer aliasing to LiteLLM/models.dev keys over duplicating prices in the supplement. +- USD per million tokens everywhere. Exact numbers from the Cursor page, never inferred. +- Prefer aliasing to LiteLLM or models.dev keys over duplicating prices in the supplement. - Never delete pricing entries that historical usage may reference. - `-fast` alias rules come before their base-model rules. -- Always bump `updated_at`; always go through a PR. +- Always bump `updated_at`. Always go through a PR. diff --git a/.agents/skills/release-swift/SKILL.md b/.agents/skills/release-swift/SKILL.md index 4db5e1d16..69e70df63 100644 --- a/.agents/skills/release-swift/SKILL.md +++ b/.agents/skills/release-swift/SKILL.md @@ -5,39 +5,26 @@ description: "Cut a stable release of Runway (Swift menu-bar app): pick a versio # Release Swift -Pushing a `v*` tag on `main` runs `.github/workflows/release.yml`, which builds, signs, notarizes, attaches `Runway-.dmg` to the GitHub Release, and updates the Sparkle `appcast.xml` on `update-feed`. The same run's independent **iOS Gate** job (`script/testflight_gate.mjs`) decides whether this tag ships the iOS companion app: it skips Mac-only releases (no iOS-relevant changes since the last build distributed to the external TestFlight group(s), and that build is under 60 days old) so an unchanged app is not re-submitted for Beta App Review. When it ships, the **iOS TestFlight** job builds the iOS app at the tag's version and uploads it to App Store Connect, where TestFlight distributes it to internal testers automatically after processing; a follow-up **TestFlight External** job then adds the processed build to the external tester group(s) and submits it for Beta App Review. CI creates the release with an EMPTY body, so this skill generates the changelog, records it in `CHANGELOG.md`, and publishes the notes onto the release. +Pushing a `v*` tag on `main` runs `.github/workflows/release.yml`. It builds, signs, notarizes, attaches `Runway-.dmg` to the GitHub Release, and updates the Sparkle `appcast.xml` on `update-feed`. The same run's **iOS Gate** job (`script/testflight_gate.mjs`) decides whether this tag ships the iOS companion app. It skips Mac-only releases (no iOS-relevant changes since the last build distributed to the external TestFlight group, and that build under 60 days old). When it ships, the **iOS TestFlight** job builds and uploads the iOS app, and the **TestFlight External** job adds the processed build to the external tester group and submits it for Beta App Review. CI creates the release with an empty body, so this skill generates the changelog, records it in `CHANGELOG.md`, and publishes the notes onto the release. -Runway has one stable release channel. Tags use `vMAJOR.MINOR.PATCH`; suffixed prerelease tags are -rejected. The tag is the version (`v0.7.1` becomes `CFBundleShortVersionString = 0.7.1`), and -`CFBundleVersion` is the git commit count. There are no version files to bump. +Runway has one release channel. Tags are `vMAJOR.MINOR.PATCH`. Suffixed prerelease tags are rejected. The tag is the version (`v0.7.1` becomes `CFBundleShortVersionString = 0.7.1`), and `CFBundleVersion` is the git commit count. There are no version files to bump. ## Cutting a release ### 0. Preflight: iOS signing assets -Only needed when the release will actually ship iOS — anything under `ios/` or the TestFlight -pipeline changed since the last externally distributed TestFlight build, that build is over 60 -days old, or the run will use the `force_ios` dispatch input. For a Mac-only release the iOS Gate skips the -iOS jobs and none of this applies. +Only needed when the release will ship iOS: anything under `ios/` or the TestFlight pipeline changed since the last externally distributed build, that build is over 60 days old, or the run will use the `force_ios` input. For a Mac-only release, skip this step. -Check this **before** tagging. A profile problem fails the iOS job during signing, which is *before* -the upload consumes the TestFlight build number — so it is recoverable on the same tag: fix the -secret and `gh run rerun --failed` (see step 7). Preflighting saves that round trip, not -the release. +Check this before tagging. A profile problem fails the iOS job during signing, before the upload consumes the TestFlight build number, so it is recoverable on the same tag: fix the secret and `gh run rerun --failed` (see step 7). Preflighting saves that round trip. Both App Store profiles must exist and be `ACTIVE`: - `Runway Mobile App Store` → `com.mattstallone.runway.mobile` - `Runway Mobile Widgets App Store` → `com.mattstallone.runway.mobile.widgets` -**Editing an App ID's capabilities silently invalidates every existing profile built on it.** Nothing -warns you; the profile flips to `INVALID` and signing fails much later with an unrelated-looking -error. If capabilities changed since the last release, regenerate both profiles and refresh -`APPLE_IOS_APP_STORE_PROFILE` / `APPLE_IOS_WIDGET_APP_STORE_PROFILE` in the same sitting. +Editing an App ID's capabilities silently invalidates every existing profile built on it. The profile flips to `INVALID` and signing fails later with an unrelated-looking error. If capabilities changed since the last release, regenerate both profiles and refresh `APPLE_IOS_APP_STORE_PROFILE` and `APPLE_IOS_WIDGET_APP_STORE_PROFILE` together. -Both App IDs must keep **both** iCloud containers enabled — `iCloud.com.mattstallone.runway` for -Release and `iCloud.com.mattstallone.runway.dev` for Debug. A profile granting only one signs one -configuration and breaks the other. +Both App IDs must keep both iCloud containers enabled: `iCloud.com.mattstallone.runway` for Release and `iCloud.com.mattstallone.runway.dev` for Debug. A profile granting only one signs one configuration and breaks the other. Verify a downloaded profile before trusting it: @@ -51,13 +38,11 @@ Expect both containers and both environments (`Production`, `Development`). ### 1. Choose the version -Propose the next stable version (default bump: patch) and confirm it with the owner before proceeding. +Propose the next version (default: patch bump) and get the owner's confirmation before going on. ### 2. Generate the changelog -Collect commits since the **previous stable release** and categorize each. The inherited history -contains old beta tags, so do not use the nearest tag blindly: span from the last plain stable tag -(e.g. `v0.7.0...v0.7.1`) so all intervening commits are included. +Collect commits since the previous stable release and categorize each. The inherited history contains old beta tags, so do not use the nearest tag blindly. Span from the last plain stable tag (for example `v0.7.0...v0.7.1`). | Commit prefix | Category | |---|---| @@ -67,7 +52,7 @@ contains old beta tags, so do not use the nearest tag blindly: span from the las | `chore`, `style`, `docs`, `perf`, `test`, `ci`, `build` | Chores | | Uncategorized | Bug Fixes | -Author attribution (required on every entry): +Author attribution is required on every entry: - With a PR number `(#123)`, resolve the PR from the commit rather than assuming its repository: @@ -77,12 +62,9 @@ Author attribution (required on every entry): if . == null then null else {url: .html_url, author: .user.login} end' ``` - Use the returned `url` and `author`. This is required for the first fork release because its range - contains inherited upstream PRs. The endpoint also returns fork PRs for commits merged in this - repository, so overlapping PR-number namespaces are handled by commit provenance. + Use the returned `url` and `author`. The endpoint returns fork PRs for commits merged in this repository, so overlapping PR-number namespaces are handled by commit provenance. - Without a PR number: `gh api /repos/mstallone/runway/commits/{full_hash} -q '.author.login'`. -- If the PR lookup returns null, omit the PR link and use the commit attribution lookup. If that API - also returns null, fall back to the git author name. +- If the PR lookup returns null, omit the PR link and use the commit attribution lookup. If that also returns null, use the git author name. Output the changelog in a code block (template below) for review. @@ -92,8 +74,7 @@ Wait for explicit approval of the changelog before changing any files. Accept ed ### 4. Record it in CHANGELOG.md -`main` is a protected branch: pushing to it directly is rejected, so the changelog lands through a -PR. Prepend the approved section right after the `# Changelog` header, then: +`main` is protected, so the changelog lands through a PR. Prepend the approved section right after the `# Changelog` header, then: ```sh git switch main && git pull @@ -111,9 +92,7 @@ gh pr merge {pr} --squash --delete-branch ### 5. Tag the merged commit and push -Tag **after** the merge, so the tag points at a commit that is on `main`. Tagging first leaves the -release tag off `main` forever (the squash-merge rewrites the commit), which pollutes the next -release's changelog range with the previous release's own changelog commit. +Tag after the merge, so the tag points at a commit on `main`. Tagging first leaves the release tag off `main` forever (the squash-merge rewrites the commit), which pollutes the next release's changelog range. ```sh git switch main && git pull @@ -121,12 +100,11 @@ git tag -a v{version} -m "v{version}" git push origin v{version} ``` -Pushing the tag is what starts the release run — there is no `git push origin main` step, because -the merge already updated it. +Pushing the tag starts the release run. There is no `git push origin main` step. ### 6. Publish the notes -CI creates the release with an empty body, so attach the approved notes after it finishes: +CI creates the release with an empty body. Attach the approved notes after it finishes: ```sh gh run watch @@ -136,10 +114,7 @@ gh release edit v{version} --notes-file /tmp/notes-v{version}.md Never leave a release blank. -A failed first-release run is safe to rerun. If the GitHub Release for the current tag was published -but the appcast was not, the workflow rebuilds the fresh feed only when that tag is the fork's sole -release. If any older release history exists while `appcast.xml` is missing, it aborts rather than -silently dropping prior Sparkle entries. +A failed first-release run is safe to rerun. If the GitHub Release for the current tag was published but the appcast was not, the workflow rebuilds the feed only when that tag is the fork's sole release. If older release history exists while `appcast.xml` is missing, it aborts rather than dropping prior Sparkle entries. ### 7. Verify (never leave a draft) @@ -150,29 +125,18 @@ git fetch origin update-feed && git show origin/update-feed:appcast.xml | grep - curl -s "https://mstallone.github.io/runway/appcast.xml" | grep -F "Runway-{version}.dmg" ``` -The second check matters: publishing is two hops — Release (or pricing-supplement) pushes `appcast.xml` to the **`update-feed` branch**, then **`.github/workflows/deploy-update-feed.yml` on `main`** deploys that branch to the live site (Pages source is "GitHub Actions", not legacy branch deploy). The Release macOS job dispatches the deploy immediately after publishing the branch (so Sparkle clients never wait on the slower iOS TestFlight jobs), with `workflow_run` completion as a fallback trigger; GitHub sometimes returns **"Deployment failed, try again later"** even though `update-feed` is already correct. If the branch has the version but the live URL does not after ~10 minutes, check `gh run list --workflow=deploy-update-feed.yml` and re-run **`gh workflow run deploy-update-feed.yml --ref main`** (must use `main` — the workflow file is not on `update-feed`). Sparkle clients only see the live URL. +The last check matters. Publishing is two hops: Release (or pricing-supplement) pushes `appcast.xml` to the `update-feed` branch, then `.github/workflows/deploy-update-feed.yml` on `main` deploys that branch to the live site (Pages source is "GitHub Actions"). The Release macOS job dispatches the deploy right after publishing the branch, with `workflow_run` completion as a fallback trigger. GitHub sometimes returns "Deployment failed, try again later" even though `update-feed` is correct. If the branch has the version but the live URL does not after about 10 minutes, check `gh run list --workflow=deploy-update-feed.yml` and re-run `gh workflow run deploy-update-feed.yml --ref main` (it must be `main`; the workflow file is not on `update-feed`). Sparkle clients only see the live URL. -Require `isDraft=false`, `isPrerelease=false`, `Runway-.dmg` and -`Runway-.dmg.sha256` assets, `bodyLen>0`, and the version present in the appcast. +Require `isDraft=false`, `isPrerelease=false`, the `Runway-.dmg` and `Runway-.dmg.sha256` assets, `bodyLen>0`, and the version in the appcast. -Also confirm the iOS jobs in the same run did what the gate decided (`gh run view` shows all -jobs). **iOS Gate** must be green; its log states SHIP or SKIP and why. If it said SKIP, the -**iOS TestFlight** and **TestFlight External** jobs are skipped — that is the expected outcome -for a Mac-only release, not a failure. If it said SHIP: +Also confirm the iOS jobs did what the gate decided (`gh run view` shows all jobs). **iOS Gate** must be green. Its log says SHIP or SKIP and why. If SKIP, the **iOS TestFlight** and **TestFlight External** jobs are skipped. That is the expected outcome for a Mac-only release. If SHIP: -- **iOS TestFlight** green means the build was uploaded; TestFlight pushes it to internal testers - on its own once Apple finishes processing (minutes). -- **TestFlight External** green means the processed build was added to the external group(s) and - submitted for Beta App Review — external testers receive it when Apple approves (hours to ~a - day; visible in App Store Connect → TestFlight). Approval is Apple-side; nothing to babysit. +- **iOS TestFlight** green means the build was uploaded. TestFlight pushes it to internal testers once Apple finishes processing (minutes). +- **TestFlight External** green means the processed build was added to the external group and submitted for Beta App Review. External testers receive it when Apple approves (hours to about a day; visible in App Store Connect → TestFlight). Nothing to babysit. -An iOS-only failure does not invalidate the Mac release: fix the cause and rerun just the failed -job (`gh run rerun --failed`). That is always safe for **TestFlight External** (it is -idempotent), but for **iOS TestFlight** only if the upload itself never happened — a rerun after -a successful upload is rejected as a duplicate build number, and the fix then ships with the next -tag instead. If a -draft was left behind, migrate its notes/assets onto the published release, then delete it — but only -once a separate PUBLISHED release for the tag already exists: +An iOS-only failure does not invalidate the Mac release. Fix the cause and rerun just the failed job (`gh run rerun --failed`). That is always safe for **TestFlight External** (idempotent). For **iOS TestFlight** it is safe only if the upload never happened. A rerun after a successful upload is rejected as a duplicate build number, and the fix ships with the next tag instead. + +If a draft was left behind, migrate its notes and assets onto the published release, then delete it, but only once a separate published release for the tag exists: ```sh tag="v{version}" @@ -212,21 +176,18 @@ Only include category sections that have entries. - [{short_hash}](https://github.com/mstallone/runway/commit/{full_hash}) {commit message} by @{author} ~~~ -`{prev_tag}` is the previous plain stable release tag. Ignore inherited suffixed beta tags when -selecting it. +`{prev_tag}` is the previous plain stable release tag. Ignore inherited suffixed beta tags. ## Rules -- 7-char short commit hashes; tags always prefixed with `v`. -- Release tags are plain `vMAJOR.MINOR.PATCH`; never create a suffixed prerelease tag. -- Changelogs span the previous stable release to the new stable release. -- Never push or tag automatically — ask the owner first. -- Always publish notes to the GitHub Release — never blank. -- The version is the tag; never edit version files. -- `main` is protected: the changelog lands via PR, and the tag goes on the merged commit — never - tag before the merge. -- The appcast is append-only so older installs keep working; the workflow aborts rather than shrink it. -- Editing App ID capabilities invalidates existing provisioning profiles; regenerate them and - refresh the secrets before tagging. +- 7-char short commit hashes. Tags always prefixed with `v`. +- Release tags are plain `vMAJOR.MINOR.PATCH`. Never create a suffixed prerelease tag. +- Changelogs span the previous stable release to the new one. +- Never push or tag on your own. Ask the owner first. +- Always publish notes to the GitHub Release. Never blank. +- The version is the tag. Never edit version files. +- `main` is protected: the changelog lands via PR, and the tag goes on the merged commit. Never tag before the merge. +- The appcast is append-only so older installs keep working. The workflow aborts rather than shrink it. +- Editing App ID capabilities invalidates existing provisioning profiles. Regenerate them and refresh the secrets before tagging. Release secrets and one-time setup live in [docs/releasing.md](../../../docs/releasing.md#release-setup-one-time). diff --git a/.agents/skills/signing-entitlements/SKILL.md b/.agents/skills/signing-entitlements/SKILL.md deleted file mode 100644 index 9ac8b96d9..000000000 --- a/.agents/skills/signing-entitlements/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: signing-entitlements -description: Inspect signing, entitlements, hardened runtime, and Gatekeeper issues for macOS apps. Use when asked to diagnose code signing failures, missing entitlements, sandbox problems, notarization prerequisites, or trust-policy launch errors. ---- - -# Signing & Entitlements - -## Quick Start - -Use this skill when the failure smells like codesigning rather than compilation: -launch refusal, missing entitlement, invalid signature, sandbox mismatch, -hardened runtime confusion, or trust-policy rejection. - -## Workflow - -1. Inspect the bundle or binary. - - Locate the `.app` or executable. - - Identify the main binary inside `Contents/MacOS/`. - -2. Read signing details. - - Use `codesign -dvvv --entitlements :- `. - - Use `spctl -a -vv ` when Gatekeeper behavior matters. - - Use `plutil -p` for entitlements or Info.plist inspection. - -3. Classify the failure. - - Unsigned or ad hoc signed - - Wrong identity - - Entitlement mismatch - - Hardened runtime issue - - App Sandbox issue - - Nested code signing issue - - Distribution/notarization prerequisite issue - -4. Explain the minimum fix path. - - Say exactly what is wrong. - - Show the shortest set of validation or repair commands. - - Distinguish local development problems from distribution problems. - -## Useful Commands - -- `codesign -dvvv --entitlements :- ` -- `spctl -a -vv ` -- `xcrun swift script/find_codesigning_identity.swift ` -- `plutil -p ` - -## Guardrails - -- Never invent missing entitlements. -- Do not conflate notarization with local debug signing. -- If the real issue is a build setting or provisioning profile, say so directly. - -## Output Expectations - -Provide: -- what artifact was inspected -- what signing state it is in -- the exact failure class -- the minimum fix or validation sequence diff --git a/.agents/skills/swiftpm-macos/SKILL.md b/.agents/skills/swiftpm-macos/SKILL.md deleted file mode 100644 index 32e3e6327..000000000 --- a/.agents/skills/swiftpm-macos/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: swiftpm-macos -description: Build, run, and test pure SwiftPM-based macOS packages and executables. Use when the repo is package-first, when there is no Xcode project, or when Swift package workflows are the fastest path to diagnosis. ---- - -# SwiftPM for macOS - -## Quick Start - -Use this skill when `Package.swift` is the primary entrypoint or when SwiftPM is -the fastest path to a reproducible result. - -## Workflow - -1. Inspect the package. - - Read `Package.swift`. - - Identify executable, library, and test products. - -2. Build with SwiftPM. - - Use `swift build` by default. - - Use release mode only when the user explicitly needs it. - -3. Run the right product. - - Use `swift run ` when an executable exists. - - If multiple executables exist, explain the default choice. - -4. Test narrowly. - - Use `swift test`. - - Apply filters when a specific test target or case is known. - -5. Summarize failures. - - Module/import resolution - - Package graph or dependency issue - - Linker failure - - Runtime failure - - Test regression - -## Guardrails - -- Prefer SwiftPM over Xcode when both exist and the package path is clearly simpler. -- Do not assume an app bundle exists in a pure package workflow. -- Explain when the package is library-only and therefore not directly runnable. - -## Output Expectations - -Provide: -- the package products you found -- the command you ran -- whether build, run, or test succeeded -- the top blocker if not diff --git a/.agents/skills/swiftui-patterns/SKILL.md b/.agents/skills/swiftui-patterns/SKILL.md deleted file mode 100644 index a0f578446..000000000 --- a/.agents/skills/swiftui-patterns/SKILL.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -name: swiftui-patterns -description: Best practices and example-driven guidance for building native macOS SwiftUI scenes and components, including windows, commands, toolbars, settings, split views, inspectors, menu bar extras, and keyboard-driven workflows. Use when creating or refactoring macOS SwiftUI UI, choosing scene types, wiring menus or settings, or needing desktop-specific component patterns and examples. ---- - -# SwiftUI Patterns - -## Quick Start - -Choose a track based on your goal: - -### Existing project - -- Identify the feature or scene and the primary interaction model: document, editor, sidebar-detail, utility window, settings, or menu bar extra. -- Read the nearest existing scene or root view before inventing a new desktop structure. -- Choose the relevant reference from `references/components-index.md`. -- If SwiftUI cannot express the required platform behavior cleanly, use the `appkit-interop` skill rather than forcing a shaky workaround. - -### New app scaffolding - -- Choose the scene model first: `WindowGroup`, `Window`, `Settings`, `MenuBarExtra`, or `DocumentGroup`. -- If the app combines a normal main window and a `MenuBarExtra`, use `WindowGroup(..., id:)` for the primary window when it should appear at launch. Treat `Window(...)` as a better fit for auxiliary/on-demand singleton windows; in menu-bar-heavy apps, a `Window(...)` scene may not present the main window automatically at launch. -- Before creating the scaffold, check whether the workspace is already inside a git repo with `git rev-parse --is-inside-work-tree`. If not, run `git init` at the project root so git-backed editor features unlock from the start. Do not initialize a nested repo inside an existing parent checkout. -- For a new app scaffold, also create one project-local `script/build_and_run.sh` so the app has a single kill + build + run entrypoint from the start. Use the exact bootstrap contract from `build-run-debug` and its `references/build-script.md` file rather than inventing a second variant here. -- Decide which state is app-wide, scene-scoped, or window-scoped before writing views. -- Sketch file and module boundaries before writing the full UI. For any non-trivial app, create the folder structure first and split files by responsibility from the start. -- Use a single Swift file only for tiny throwaway examples or snippets: roughly under 50 lines, one screen, no persistence, no networking/process client, and no reusable models. Anything beyond that should be multi-file immediately. -- Use system-adaptive colors and materials by default (`Color.primary`, `Color.secondary`, semantic foreground styles, `.regularMaterial`, etc.) so the app follows Light/Dark mode automatically. Do not hardcode white or light backgrounds unless the user explicitly asks for a fixed theme, and do not reach for opaque `windowBackgroundColor` fills for root panes by default. -- Pick the references for the first feature surface you need: windowing, commands, split layouts, or settings. - -## New App File Structure - -For any non-trivial macOS app, start with this shape instead of putting the app, -all views, models, stores, services, and helpers in one Swift file: - -- `App/App.swift`: the `@main` app type and `AppDelegate` only. -- `Views/ContentView.swift`: root layout and high-level composition only. -- `Views/SidebarView.swift`, `Views/DetailView.swift`, `Views/ComposerView.swift`, etc.: feature views named after their primary type. -- `Models/*.swift`: value models, identifiers, and selection enums. -- `Stores/*.swift`: persistence and state stores. -- `Services/*.swift`: app-server, network, process, or platform clients. -- `Support/*.swift`: small formatters, resolvers, extensions, and glue helpers. - -Keep files small and named after the primary type they contain. If a file starts -collecting unrelated views, models, stores, networking clients, and helper -extensions, split it before adding more behavior. - -## Pre-Edit Checklist For New App Scaffolds - -Before writing the full UI: - -1. Choose the scene model. -2. Choose state ownership: app-wide, scene-scoped, window-scoped, or view-local. -3. Sketch file and module boundaries. -4. Create the folder structure before filling in the UI. -5. Keep `script/build_and_run.sh` separate from app source. - -## General Rules To Follow - -- Design for pointer, keyboard, menus, and multiple windows. -- Keep scenes explicit. A separate settings window, utility window, or menu bar extra should be modeled as its own scene, not hidden inside one monolithic `ContentView`. -- Prefer system desktop affordances: `commands`, toolbars, sidebars, inspectors, contextual menus, and `searchable`. -- For menu bar apps, keep `MenuBarExtra` item titles and action labels short and scannable. Cap visible menu item text at 30 characters; if source content is longer, truncate or summarize it before rendering and open the full content in a dedicated window or detail surface. -- If a `MenuBarExtra` app should still behave like a regular Dock app with a visible main window/process, install an `NSApplicationDelegate` via `@NSApplicationDelegateAdaptor`, call `NSApp.setActivationPolicy(.regular)` during launch, and activate the app with `NSApp.activate(ignoringOtherApps: true)`. If the app is intentionally menu-bar-only, document that `.accessory` / no-Dock behavior is a deliberate product choice. -- Prefer system-adaptive colors, materials, and semantic foreground styles. Avoid fixed white/light backgrounds in scaffolding and examples unless the requested design explicitly calls for a custom non-adaptive theme. -- Do not paint `NavigationSplitView` sidebars or root window panes with opaque custom `Color(...)` or `Color(nsColor: .windowBackgroundColor)` fills by default. Prefer native macOS sidebar/window materials and system-provided backgrounds unless the user explicitly asks for a custom opaque surface. In sidebar-detail-inspector layouts, let the sidebar keep the standard source-list/material appearance and reserve custom backgrounds for detail or inspector content cards where needed. -- Use `@SceneStorage` for per-window ephemeral state and `@AppStorage` for durable user preferences. -- Keep selection state explicit and stable. macOS layouts often pivot around sidebar selection rather than push navigation. -- Prefer `NavigationSplitView` or a deliberate manual split layout over iOS-style stacked flows when the app benefits from always-visible structure. -- For `List(...).listStyle(.sidebar)` and `NavigationSplitView` sidebars, prefer flat native rows with standard system selection/highlight behavior. Keep rows visually lightweight and Mail-like: at most one leading icon, one strong title line, and one optional secondary detail line in `.secondary`. Avoid stacked metadata rows, repeated inline utility icons, or dense multi-column status text in the sidebar. Reserve card-style and metadata-heavy surfaces for detail or inspector panes unless the user explicitly asks for a highly custom sidebar treatment. -- Keep primary actions discoverable from both UI chrome and keyboard shortcuts when appropriate. -- Use SwiftUI-native scenes and views first. If you need low-level window, responder-chain, text system, or panel control, switch to `appkit-interop`. - -## Recommended Sidebar Row Pattern - -Prefer a native source-list row shape: - -```swift -List(selection: $selection) { - ForEach(items) { item in - HStack(spacing: 10) { - Image(systemName: item.systemImage) - .foregroundStyle(.secondary) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .lineLimit(1) - - if let detail = item.detail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - .tag(item.id) - } -} -.listStyle(.sidebar) -``` - -This keeps selection, highlight, spacing, and scanability aligned with standard -macOS sidebars. Keep each row to one icon maximum and one or two text lines -maximum, with the second line reserved for a short detail label. Use richer card -treatments and denser metadata in the detail or inspector content, not in every -sidebar row. - -## Recommended Split-View Background Pattern - -Prefer letting the sidebar and split container use system backgrounds, while -applying custom surfaces only to detail cards or inspector sections: - -```swift -NavigationSplitView { - List(selection: $selection) { - ForEach(items) { item in - Label(item.title, systemImage: item.systemImage) - .tag(item.id) - } - } - .listStyle(.sidebar) -} detail: { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - DetailSummaryCard(item: selectedItem) - DetailMetricsCard(item: selectedItem) - } - .padding() - } -} -``` - -Avoid painting the sidebar and root split panes with opaque custom fills by -default: - -```swift -NavigationSplitView { - List(items) { item in - SidebarCardRow(item: item) - } - .listStyle(.sidebar) - .background(Color(nsColor: .windowBackgroundColor)) -} detail: { - DetailView(item: selectedItem) - .background(Color(.white)) -} -``` - -## State Ownership Summary - -Use the narrowest state tool that matches the ownership model: - -| Scenario | Preferred pattern | -| --- | --- | -| Local view or control state | `@State` | -| Child mutates parent-owned value state | `@Binding` | -| Root-owned reference model on macOS 14+ | `@State` with an `@Observable` type | -| Child reads or mutates an injected `@Observable` model | Pass it explicitly as a stored property | -| Window-scoped ephemeral selection or expansion state | `@SceneStorage` when practical, otherwise scene-owned `@State` | -| Shared user preference | `@AppStorage` | -| Shared app service or configuration | `@Environment(Type.self)` | -| Legacy reference model on older targets | `@StateObject` at the owner and `@ObservedObject` when injected | - -Choose the ownership location first, then the wrapper. Do not turn simple desktop state into a view model by reflex. - -## Cross-Cutting References - -- `references/components-index.md`: entry point for scene and component guidance. -- `references/windowing.md`: choosing between `WindowGroup`, `Window`, `DocumentGroup`, and window-opening patterns. -- `references/settings.md`: dedicated settings scenes, `SettingsLink`, and preference layouts. -- `references/commands-menus.md`: command menus, keyboard shortcuts, focused values, and desktop action routing. -- `references/split-inspectors.md`: sidebars, split views, selection-driven layout, and inspectors. -- `references/menu-bar-extra.md`: menu bar extra structure and when it fits. - -## Anti-Patterns - -- One huge `ContentView` pretending the whole app is a single screen. -- A single Swift file containing the `@main` app, all views, models, stores, networking/process clients, formatters, and extensions. This is acceptable only for tiny throwaway snippets under the new-app threshold above. -- Touch-first interaction models ported directly from iOS without desktop affordances. -- Hiding core actions behind gestures with no menu, toolbar, or keyboard path. -- Building a menu-bar-plus-window app around only a `Window(...)` scene and then expecting the main window to appear at launch. Use `WindowGroup(..., id:)` for the primary launch window and reserve `Window(...)` for auxiliary/on-demand windows. -- Rendering full unbounded document titles, prompts, or message text directly inside a menu bar extra. Menu item labels should stay at or below 30 characters, with longer content moved into a dedicated window or detail view. -- Treating settings as another navigation destination in the main content window. -- Hardcoding `.background(.white)`, `Color.white`, or a fixed light palette in a brand-new scaffold without an explicit design requirement. -- Wrapping each sidebar item in large rounded custom cards inside a `.sidebar` list, which fights native source-list density, alignment, and selection behavior unless the user explicitly asked for a bespoke visual sidebar. -- Building sidebar rows with multiple repeated icons, three or more text lines, or a dense strip of inline metadata counters/timestamps/models. Keep the sidebar row to one icon and one or two text lines, then move richer metadata into the detail pane. -- Painting `NavigationSplitView` sidebars or root window panes with opaque custom color fills by default, instead of letting the sidebar use native source-list/material appearance and reserving custom backgrounds for actual content cards. -- Using push navigation for layouts that want stable sidebar selection and detail panes. -- Reaching for AppKit before the SwiftUI scene and command APIs have been used properly. - -## Workflow For A New macOS Scene Or View - -1. Define the scene type and ownership model before writing child views. -2. Decide which actions live in content, toolbars, commands, inspectors, or settings. -3. Sketch the selection model and layout: sidebar-detail, editor-inspector, document window, or utility window. -4. Create the file/folder structure for app entrypoint, root layout, feature views, models, stores, services, and support helpers. -5. Build with small, focused subviews and explicit inputs rather than giant computed fragments. -6. Add keyboard shortcuts and menu or toolbar exposure for actions that matter on desktop. -7. Validate the flow with a build and a quick usability pass: multiwindow assumptions, settings entry points, and selection stability. - -## Component References - -Use `references/components-index.md` as the entry point. Each component reference should include: -- intent and best-fit scenarios -- minimal usage pattern with desktop conventions -- pitfalls and discoverability notes -- when to fall back to `appkit-interop` diff --git a/.agents/skills/swiftui-patterns/references/commands-menus.md b/.agents/skills/swiftui-patterns/references/commands-menus.md deleted file mode 100644 index 47fb3b750..000000000 --- a/.agents/skills/swiftui-patterns/references/commands-menus.md +++ /dev/null @@ -1,41 +0,0 @@ -# Commands and Menus - -## Intent - -Use this when mapping desktop actions into menu items, keyboard shortcuts, and focused scene behavior. - -## Core patterns - -- Add `commands` at the scene level. -- Use `CommandMenu` for app-specific actions. -- Use `CommandGroup` to insert, replace, or remove menu sections. -- Use `FocusedValue` or scene state to make commands context-sensitive. -- Pair important commands with keyboard shortcuts and visible toolbar or content affordances when appropriate. - -## Example - -```swift -@main -struct SampleApp: App { - var body: some Scene { - WindowGroup { - EditorRootView() - } - .commands { - CommandMenu("Document") { - Button("New Note") { /* create */ } - .keyboardShortcut("n") - - Button("Toggle Inspector") { /* toggle */ } - .keyboardShortcut("i", modifiers: [.command, .option]) - } - } - } -} -``` - -## Pitfalls - -- Do not register the same shortcut in multiple places. -- Do not make commands the only discoverable path for a critical action. -- If you need responder-chain validation, custom menu item state, or AppKit-specific command behavior, use `appkit-interop`. diff --git a/.agents/skills/swiftui-patterns/references/components-index.md b/.agents/skills/swiftui-patterns/references/components-index.md deleted file mode 100644 index 0fe243272..000000000 --- a/.agents/skills/swiftui-patterns/references/components-index.md +++ /dev/null @@ -1,16 +0,0 @@ -# Components Index - -Use this file to find scene and component guidance. Each entry lists when to use it. - -## Available references - -- Windowing: `references/windowing.md` — Use when choosing between `WindowGroup`, `Window`, `DocumentGroup`, or window-opening APIs. -- Settings: `references/settings.md` — Use for dedicated settings scenes, preference storage, and settings entry points. -- Commands and menus: `references/commands-menus.md` — Use for menu items, keyboard shortcuts, focused actions, and command routing. -- Split views and inspectors: `references/split-inspectors.md` — Use for sidebar-detail apps, inspectors, and selection-driven desktop layouts. -- Menu bar extras: `references/menu-bar-extra.md` — Use when the app belongs primarily in the menu bar. - -## Adding entries - -- Add a new reference file when a macOS-specific pattern comes up repeatedly. -- Keep each reference short, actionable, and explicit about when SwiftUI is enough versus when AppKit interop is warranted. diff --git a/.agents/skills/swiftui-patterns/references/menu-bar-extra.md b/.agents/skills/swiftui-patterns/references/menu-bar-extra.md deleted file mode 100644 index f0d243d8b..000000000 --- a/.agents/skills/swiftui-patterns/references/menu-bar-extra.md +++ /dev/null @@ -1,67 +0,0 @@ -# Menu Bar Extra - -## Intent - -Use this when the app primarily lives in the macOS menu bar instead of a traditional always-open window. - -## Core patterns - -- Use `MenuBarExtra` for lightweight utilities, status indicators, and quick actions. -- If the app also has a primary main window that should appear at launch, define that scene with `WindowGroup(..., id:)` and use `Window(...)` only for auxiliary/on-demand windows. -- If the menu bar app should still show in the Dock and activate like a normal app, install an app delegate with `@NSApplicationDelegateAdaptor`, call `NSApp.setActivationPolicy(.regular)` during launch, and then `NSApp.activate(ignoringOtherApps: true)`. -- If the app is intentionally menu-bar-only, explicitly document that `.accessory` / no-Dock behavior is expected product behavior rather than a launch bug. -- Keep the menu content concise and action-oriented. -- Keep each visible menu item label to 30 characters or fewer. If the backing content can be longer than that, derive a short display title and open the full text in a separate window or detail pane. -- If the app has deeper workflows, open a dedicated window from the menu bar extra rather than cramming everything into the menu. - -## Example - -This snippet shows scene wiring only. In a real non-trivial app, keep the -`@main` app and `AppDelegate` in `App/App.swift`, and put the menu bar, -root content, and supporting models/services in separate files named after their -primary types. - -```swift -import AppKit - -private func shortMenuTitle(_ title: String) -> String { - if title.count <= 30 { - return title - } - return String(title.prefix(27)) + "..." -} - -final class AppDelegate: NSObject, NSApplicationDelegate { - func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - } -} - -@main -struct SampleApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - - var body: some Scene { - WindowGroup("Sample", id: "main") { - ContentView() - } - - MenuBarExtra("Sample", systemImage: "bolt.circle") { - Button(shortMenuTitle("Open Dashboard")) { /* open window */ } - Divider() - Button("Quit") { - NSApplication.shared.terminate(nil) - } - } - } -} -``` - -## Pitfalls - -- Do not rely on a `Window(...)` scene alone for the main launch window in a menu-bar-plus-window app when the product expects a regular window at startup. -- Do not silently ship a no-Dock menu-bar-only app if the user expects a normal app process. Either install the app delegate and switch to `.regular`, or clearly document that `.accessory` behavior is intentional. -- Do not turn the menu bar extra into a tiny, overloaded substitute for a full app window. -- Do not render raw unbounded titles or message bodies as menu items. Long labels quickly blow out the menu width and should be capped to 30 characters with a short display title. -- If the extra needs advanced status item customization or AppKit menu control, use `appkit-interop`. diff --git a/.agents/skills/swiftui-patterns/references/settings.md b/.agents/skills/swiftui-patterns/references/settings.md deleted file mode 100644 index e974b2fac..000000000 --- a/.agents/skills/swiftui-patterns/references/settings.md +++ /dev/null @@ -1,55 +0,0 @@ -# Settings - -## Intent - -Use this when building a native macOS settings window with SwiftUI. - -## Core patterns - -- Declare a dedicated `Settings` scene in the app. -- Keep settings content in a separate root view. -- Use `@AppStorage` for user preferences that should persist. -- Prefer tabs, sections, or a split settings layout over deep push navigation. -- Use `SettingsLink` or `OpenSettingsAction` for in-app entry points. - -## Example - -This snippet shows scene wiring only. In a real non-trivial app, keep the -`@main` app in `App/App.swift` and put settings content in a dedicated -view file such as `Views/SettingsView.swift`. - -```swift -@main -struct SampleApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - - Settings { - SettingsView() - } - } -} - -struct SettingsView: View { - @AppStorage("showSidebarIcons") private var showSidebarIcons = true - - var body: some View { - TabView { - Form { - Toggle("Show Sidebar Icons", isOn: $showSidebarIcons) - } - .tabItem { Label("General", systemImage: "gearshape") } - } - .frame(width: 460, height: 260) - .scenePadding() - } -} -``` - -## Pitfalls - -- Do not reuse an iOS full-screen settings screen unless the app really is a direct Catalyst-style port. -- Keep settings rows simple and accessible. -- If settings require custom panels, responders, or first-responder integration, use `appkit-interop`. diff --git a/.agents/skills/swiftui-patterns/references/split-inspectors.md b/.agents/skills/swiftui-patterns/references/split-inspectors.md deleted file mode 100644 index 8fef045cc..000000000 --- a/.agents/skills/swiftui-patterns/references/split-inspectors.md +++ /dev/null @@ -1,38 +0,0 @@ -# Split Views and Inspectors - -## Intent - -Use this when the app benefits from a stable sidebar-detail layout, optional supplementary content, or an inspector panel. - -## Core patterns - -- Prefer explicit selection state over push-only navigation. -- Start with `NavigationSplitView` when the layout matches the system mental model. -- Use a manual split only when you need unusual sizing or an always-visible custom column. -- Use `inspector(isPresented:)` for lightweight detail controls that complement the main content. - -## Example: sidebar + detail - -```swift -struct LibraryRootView: View { - @State private var selection: Item.ID? - @State private var showInspector = false - - var body: some View { - NavigationSplitView { - SidebarList(selection: $selection) - } detail: { - DetailView(selection: selection) - .inspector(isPresented: $showInspector) { - InspectorView(selection: selection) - } - } - } -} -``` - -## Pitfalls - -- Avoid swapping the whole root layout with top-level conditionals when selection changes. -- Avoid hiding too much detail behind modal sheets when an inspector or secondary column would fit better. -- If the layout requires AppKit split view delegation or advanced window coordination, use `appkit-interop`. diff --git a/.agents/skills/swiftui-patterns/references/windowing.md b/.agents/skills/swiftui-patterns/references/windowing.md deleted file mode 100644 index a2764e643..000000000 --- a/.agents/skills/swiftui-patterns/references/windowing.md +++ /dev/null @@ -1,50 +0,0 @@ -# Windowing - -## Intent - -Use this when choosing the top-level scene model for a native macOS app. - -## Choose the scene type deliberately - -- Use `WindowGroup(..., id:)` for the primary app window when it should appear at launch, especially in apps that also have a `MenuBarExtra`. -- Use `WindowGroup` for any scene that can have multiple independent instances. -- Use `Window` for singleton utility windows or focused secondary surfaces. In menu-bar-heavy apps, `Window(...)` is better for auxiliary/on-demand windows and may not present the main window automatically at launch. -- Use `Settings` for preferences. Do not bury settings inside the main content flow. -- Use `DocumentGroup` when the app is fundamentally document-driven. - -## Example: main app plus utility window - -This snippet shows scene wiring only. In a real non-trivial app, keep the -`@main` app in `App/App.swift` and put `LibraryRootView`, -`InspectorRootView`, and `SettingsView` in dedicated `Views/` files. - -```swift -@main -struct SampleApp: App { - var body: some Scene { - WindowGroup("Library", id: "library") { - LibraryRootView() - } - - Window("Inspector", id: "inspector") { - InspectorRootView() - } - - Settings { - SettingsView() - } - } -} -``` - -## Opening windows - -- Use `openWindow(id:)` when a command, toolbar item, or button should open another scene. -- Keep per-window state in the scene or `@SceneStorage`, not in a single global pile. - -## Pitfalls - -- Avoid modeling every feature as a pushed destination inside one window. -- Do not use only `Window(...)` for the main launch window in a menu-bar-plus-window app unless you have verified the launch behavior and intentionally want an on-demand auxiliary window. -- Avoid singleton state for window-specific selections or drafts. -- If you need lower-level titlebar, tabbing, or window lifecycle control, switch to `appkit-interop`. diff --git a/.agents/skills/telemetry/SKILL.md b/.agents/skills/telemetry/SKILL.md deleted file mode 100644 index c0ebb3647..000000000 --- a/.agents/skills/telemetry/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: telemetry -description: Add lightweight runtime telemetry and debug instrumentation to macOS apps, then verify those events after building and running. Use when wiring `Logger` / `os.Logger`, adding log points for window/sidebar/menu-bar actions, reading runtime logs from Console or `log stream`, or confirming that expected events fire after a local run. ---- - -# Telemetry - -## Quick Start - -Use this skill to add lightweight app instrumentation that helps debug behavior -without turning the codebase into a logging landfill. Prefer Apple's unified -logging APIs and verify the events after a build/run loop. - -## Core Guidelines - -- Prefer `Logger` from the `OSLog` framework for structured app logs. -- Give each feature a clear subsystem/category pair so runtime filtering stays easy. -- Log meaningful user and app lifecycle events: window opening, sidebar selection changes, menu commands, menu bar extra actions, sync/load milestones, and unexpected fallback paths. -- Keep info logs concise and stable. Use debug logs for noisy state details. -- Do not log secrets, auth tokens, personal data, or raw document contents. -- Add signposts only when measuring timing or performance spans; do not overinstrument by default. - -## Minimal Logger Pattern - -```swift -import OSLog - -private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "SampleApp", - category: "Sidebar" -) - -@MainActor -func selectItem(_ item: SidebarItem) { - logger.info("Selected sidebar item: \(item.id, privacy: .public)") - selection = item.id -} -``` - -Use feature-specific categories like `Windowing`, `Commands`, `MenuBar`, `Sidebar`, -`Sync`, or `Import` so logs can be filtered quickly. - -## Workflow - -1. Identify the behavior that needs observability. - - Window open/close - - Sidebar or inspector selection changes - - Menu or keyboard command actions - - Menu bar extra actions - - Background load/sync/import events - - Error and recovery paths - -2. Add the smallest useful instrumentation. - - Create one `Logger` per feature area or type. - - Log action boundaries and key state transitions. - - Prefer one high-signal line per user action over noisy value dumps. - -3. Build and run the app. - - Use `build-run-debug` for the build/run loop. - - If `script/build_and_run.sh` exists, prefer `./script/build_and_run.sh --telemetry` for live telemetry checks or `./script/build_and_run.sh --logs` for broader process logs. - - Exercise the UI or command path that should emit telemetry. - -4. Read runtime logs and verify the event fired. - - Use Console.app with a process/subsystem filter when that is the fastest manual check. - - Use `log stream --style compact --predicate 'process == "AppName"'` for live terminal verification. - - Prefer tighter predicates when you know the subsystem/category: - `log stream --style compact --predicate 'subsystem == "com.example.app" && category == "Sidebar"'` - -5. Tighten or remove instrumentation. - - If the event fires, keep only the logs that remain useful for future debugging. - - If it does not fire, move the log closer to the suspected control path and rerun. - -## Verification Checklist - -- The app builds after telemetry changes. -- The relevant action emits exactly one clear log line or a small bounded sequence. -- The log can be filtered by process, subsystem, or category. -- No sensitive payloads are written to unified logs. -- Noisy temporary debug logs are removed or demoted before finishing. - -## Guardrails - -- Do not use `print` as the primary app telemetry mechanism for macOS app code. -- Do not leave a dense trail of permanent debug logs around every state mutation. -- Do not claim an event is wired correctly until you have a concrete verification path through Console, `log stream`, or captured process output. -- If the debugging task is mostly about crash/backtrace analysis rather than action telemetry, switch to `build-run-debug`. diff --git a/.agents/skills/test-triage/SKILL.md b/.agents/skills/test-triage/SKILL.md deleted file mode 100644 index 48c3d5f76..000000000 --- a/.agents/skills/test-triage/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: test-triage -description: Triage failing macOS tests across Xcode and SwiftPM workflows. Use when asked to run macOS tests, narrow failing scopes, explain assertion or crash failures, or separate real test regressions from setup and environment problems. ---- - -# Test Triage - -## Quick Start - -Use this skill to run the smallest meaningful test scope first, classify -failures precisely, and avoid treating every test failure like a product bug. - -## Workflow - -1. Detect the test harness. - - Use `xcodebuild test` for Xcode-based projects. - - Use `swift test` for SwiftPM packages. - -2. Narrow the scope. - - If the user gave a target, product, or test filter, use it. - - If not, prefer the smallest likely failing target before a full suite. - -3. Classify the result. - - Build failure - - Assertion failure - - Crash or signal - - Async timing or flake - - Environment or fixture setup issue - - Missing entitlement or host app issue - -4. Rerun intelligently. - - Use focused reruns when a specific case fails. - - Avoid burning time on full-suite reruns without new information. - -5. Summarize clearly. - - What command ran - - Which tests failed - - What kind of failure it was - - The best next proof step or fix path - -## Guardrails - -- Distinguish compilation failures from test execution failures. -- Call out when a test appears to assume iOS-only or simulator-only behavior. -- Mark likely flakes as such instead of overstating confidence. - -## Output Expectations - -Provide: -- the command used -- the smallest failing scope -- the top failure category -- a concise explanation of the likely cause -- the next rerun or fix step diff --git a/.agents/skills/view-refactor/SKILL.md b/.agents/skills/view-refactor/SKILL.md deleted file mode 100644 index 1e0484ec6..000000000 --- a/.agents/skills/view-refactor/SKILL.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: view-refactor -description: Refactor macOS SwiftUI views and scenes with strong defaults for small dedicated subviews, stable sidebar and selection structure, explicit command and toolbar ownership, scene-aware state, and narrow AppKit escape hatches. Use when cleaning up a macOS view file, splitting oversized scene roots, removing iOS-centric patterns, or tightening mixed SwiftUI/AppKit architecture. ---- - -# View Refactor - -## Overview - -Refactor macOS views toward small, explicit, stable scene and view types. Default -to native SwiftUI for layout, selection, commands, and settings. Reach for AppKit -only at the narrow edges where desktop behavior truly requires it. - -## Core Guidelines - -### 1) Model scenes explicitly - -- Break the app into meaningful scene roots: main window, settings, utility windows, inspectors, or menu bar extras. -- Do not let one giant root view silently own every desktop surface. - -### 2) Keep a predictable file shape - -- Follow this ordering unless the file already has a stronger local convention: -- Environment -- `private`/`public` `let` -- `@State` / other stored properties -- computed `var` (non-view) -- `init` -- `body` -- computed view builders / other view helpers -- helper / async functions - -### 2b) Split files by responsibility - -- For non-trivial apps, do not keep the full app, all views, models, stores, networking clients, process clients, and helpers in one Swift file. -- Accept a single Swift file only for tiny throwaway examples or snippets: roughly under 50 lines, one screen, no persistence, no networking/process client, and no reusable models. -- Use `App/App.swift` for the `@main` app and `AppDelegate` only. -- Keep `Views/ContentView.swift` focused on root layout and composition; move feature UI into files such as `Views/SidebarView.swift`, `Views/DetailView.swift`, and `Views/ComposerView.swift`. -- Move value types and selection enums into `Models/*.swift`, stores into `Stores/*.swift`, app-server/network/process clients into `Services/*.swift`, and small formatters/resolvers/extensions into `Support/*.swift`. -- Keep files small and named after the primary type they contain. - -### 3) Prefer dedicated subview types over many computed `some View` fragments - -- Extract meaningful desktop sections like sidebar rows, detail panels, inspectors, or toolbar content into focused subviews. -- Keep computed `some View` helpers small and rare. -- Pass explicit data, bindings, and actions into subviews instead of handing down the whole scene model. - -### 4) Keep selection and layout stable - -- Prefer one stable split or window layout with local conditionals inside it. -- Avoid top-level branch swapping between radically different roots when selection changes. -- Let the layout be constant; let state drive the content inside it. - -### 5) Extract commands, toolbars, and actions out of `body` - -- Do not bury non-trivial button logic inline. -- Do not mix command routing, menu state, and layout in the same block if they can be named clearly. -- Keep `body` readable as UI, not as a desktop view controller. - -### 6) Use scene and app storage intentionally - -- Use `@SceneStorage` for per-window ephemeral state when it truly helps restore the scene. -- Use `@AppStorage` for durable preferences, not transient UI toggles that only matter in one window. -- Keep scene-owned state close to the scene root. - -### 7) Keep AppKit escape hatches narrow - -- If a representable or `NSWindow` bridge exists, isolate it behind a small wrapper or helper. -- Do not let AppKit references spread through unrelated SwiftUI views. -- If the bridge starts owning the feature, re-evaluate the architecture. - -### 8) Observation usage - -- For `@Observable` reference types on modern macOS targets, store them as `@State` in the owning view. -- Pass observables explicitly to children. -- On older deployment targets, fall back to `@StateObject` and `@ObservedObject` where needed. - -## Workflow - -1. Identify the current scene boundary and whether the file is trying to do too much. -2. Reorder the file into a predictable top-to-bottom structure. -3. Extract desktop-specific sections into dedicated subview types. -4. Stabilize the root layout around selection, scenes, and commands rather than top-level branching. -5. Move action logic, command routing, and toolbar behavior into named helpers or separate types. -6. Tighten any AppKit bridge so the imperative edge is small and explicit. -7. Keep behavior intact unless the request explicitly asks for structural and behavioral changes together. - -## Refactor Checklist - -- Split oversized view files before adding more UI. -- Move pure models, identifiers, and selection enums out of view files. -- Move `Process`, `URLSession`, app-server, and platform client code out of SwiftUI views into `Services/`. -- Keep `AppDelegate` and the `@main` app entrypoint minimal. -- Build after each major split so compile errors stay local. - -## Common Smells - -- A root view that mixes window scaffolding, settings, toolbar code, command handling, and detail layout. -- A single app file that mixes app entrypoint, root layout, feature views, models, stores, service clients, and support extensions. -- iOS-style push navigation forced into a Mac sidebar-detail problem. -- Several booleans for mutually exclusive inspectors, sheets, or utility windows. -- AppKit objects passed through many SwiftUI layers without a clear ownership reason. -- Large computed view fragments standing in for real subviews. - -## Notes - -- A good macOS refactor should make scene structure, selection flow, and command ownership obvious. -- When the problem is fundamentally a missing desktop pattern, use `swiftui-patterns`. -- When the problem is fundamentally a boundary with AppKit, use `appkit-interop`. diff --git a/.agents/skills/window-management/SKILL.md b/.agents/skills/window-management/SKILL.md deleted file mode 100644 index 77575bba6..000000000 --- a/.agents/skills/window-management/SKILL.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -name: window-management -description: Customize macOS 15+ SwiftUI windows and scene behavior using Window, WindowGroup, and macOS window modifiers. Use when styling or hiding window toolbars and titles, extending drag regions with WindowDragGesture, replacing window backgrounds with materials, disabling minimize or restoration for utility windows, setting default or ideal window placement from content/display size, creating borderless windows, or tuning default launch behavior. ---- - -# Window Management - -## Overview - -Use this skill to tailor each SwiftUI window to its job. Start by identifying -which scene owns the window (`Window`, `WindowGroup`, or a dedicated utility -scene), then customize the toolbar/title area, background material, resize and -restoration behavior, and initial or zoomed placement. - -Prefer scene and window modifiers over ad hoc AppKit bridges when SwiftUI offers -the behavior directly. Keep each window purpose-built: a main browser window, an -About window, and a media player window usually want different chrome, -resizability, restoration, and placement rules. - -These APIs are macOS 15+ SwiftUI window/scene customizations. For older -deployment targets, expect to use more AppKit bridging or availability guards. - -## Workflow - -1. Inspect the relevant scene declaration and classify the window role: - main app navigation, inspector/detail utility, About/support window, media - playback window, welcome window, or a borderless custom surface. -2. Adjust toolbar and title presentation to match the content. -3. If the toolbar background or entire toolbar is hidden, make sure the window - still has a usable drag region. -4. Refine window behavior for that role: minimize availability, restoration, - resize expectations, and whether the window should appear at launch. -5. Set default placement for newly opened windows and ideal placement for zoom - behavior when content and display size matter. -6. Build and launch the app with `build-run-debug` to verify the result in - a real foreground `.app` bundle. -7. If SwiftUI scene/window modifiers are not enough, switch to `appkit-interop` - for a narrow `NSWindow` bridge rather than spreading AppKit through the view - tree. - -## Toolbar And Title - -- Use `.toolbar(removing: .title)` when the window title should stay associated - with the window for accessibility and menus, but not be visibly drawn in the - title bar. -- Use `.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)` when large - media or hero content should visually extend to the top edge of the window. -- If the window still needs close/minimize/full-screen controls, remove only the - title and toolbar background. If the toolbar should disappear entirely, use - `.toolbarVisibility(.hidden, for: .windowToolbar)` instead. -- Remove custom toolbar backgrounds and manually painted titlebar fills before - layering new SwiftUI toolbar APIs on top. -- Keep the window's logical title meaningful even if hidden; the system can - still use it for accessibility and menu items. These are visual changes only. - -## Drag Regions - -- If a toolbar background is hidden or the toolbar is removed entirely, use - `WindowDragGesture()` to extend the draggable area into your content. -- Attach the gesture to a transparent overlay or non-interactive header region - that does not steal gestures from real controls. -- For a media player with custom playback controls, insert the drag overlay - between the video content and the controls so AVKit or transport controls keep - receiving input. -- Pair the drag gesture with `.allowsWindowActivationEvents(true)` so clicking - and immediately dragging a background window still activates and moves it. - -## Background And Materials - -- Use `.containerBackground(.thickMaterial, for: .window)` when a utility window - or About window should replace the default window background with a subtle - frosted material. -- Prefer system materials for stylized windows instead of hardcoded translucent - colors. -- Use this especially for fixed-content utility windows where a softer backdrop - is part of the design. - -## Window Behavior - -- Use `.windowMinimizeBehavior(.disabled)` for always-reachable utility windows - such as a custom About window where minimizing adds little value. -- Disable the green zoom control through fixed sizing or window constraints when - the window's content has one intended size. -- Use `.restorationBehavior(.disabled)` for windows that should not reopen on - next launch, such as About panels, transient support/info windows, or - first-run welcome surfaces. -- Keep state restoration enabled for primary document or navigation windows when - reopening prior size and position is desirable. -- By default, SwiftUI respects the user's system-wide macOS state restoration - setting. Use `restorationBehavior(...)` only when a specific window should - intentionally opt into or out of that system behavior. -- Use `.defaultLaunchBehavior(.presented)` for windows that should appear first - on launch, such as a welcome window, and choose that behavior intentionally - rather than relying on side effects from scene creation order. - -## Window Placement - -- Use `.defaultWindowPlacement { content, context in ... }` to control the - initial size and optional position of newly opened windows. -- Inside the placement closure, call `content.sizeThatFits(.unspecified)` to get - the content's ideal size. -- Read `context.defaultDisplay.visibleRect` to get the display's usable region - after accounting for the menu bar and Dock. -- Return `WindowPlacement(size: size)` with a size clamped to the visible rect - when media or document content may be larger than the display. If no position - is provided, the window is centered by default. -- Use `.windowIdealPlacement { content, context in ... }` to control what - happens when the user chooses Zoom from the Window menu or Option-clicks the - green toolbar button. For media windows, preserve aspect ratio and grow to the - largest size that fits the display. -- Treat default placement and ideal placement as separate policies: - - default placement controls where a new window first appears, - - ideal placement controls how large a zoomed window should become. -- Always consider external displays and rotated/narrow screens when sizing - player windows or document windows from content dimensions. - -## Borderless And Specialized Windows - -- Use `.windowStyle(.plain)` for borderless or highly custom chrome windows, but - make sure the content still provides a clear drag/move affordance and visible - context. -- For a borderless player, HUD, or welcome window, decide upfront whether losing - standard titlebar affordances is worth the custom presentation. -- Keep one clear path back to regular window management if the plain style makes - the window feel invisible or hard to move. - -## API Snippets - -```swift -WindowGroup("Destination Video") { - CatalogView() - .toolbar(removing: .title) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) -} -``` - -```swift -Window("About", id: "about") { - AboutView() - .toolbar(removing: .title) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - .containerBackground(.thickMaterial, for: .window) -} -.windowMinimizeBehavior(.disabled) -.restorationBehavior(.disabled) -``` - -```swift -WindowGroup("Player", for: Video.self) { $video in - PlayerView(video: video) -} -.defaultWindowPlacement { content, context in - let idealSize = content.sizeThatFits(.unspecified) - let displayBounds = context.defaultDisplay.visibleRect - let fittedSize = clampToDisplay(idealSize, displayBounds: displayBounds) - return WindowPlacement(size: fittedSize) -} -.windowIdealPlacement { content, context in - let idealSize = content.sizeThatFits(.unspecified) - let displayBounds = context.defaultDisplay.visibleRect - let zoomedSize = zoomToFit(idealSize, displayBounds: displayBounds) - let position = centeredPosition(for: zoomedSize, in: displayBounds) - return WindowPlacement(position, size: zoomedSize) -} -``` - -```swift -PlayerView(video: video) - .overlay(alignment: .top) { - Color.clear - .frame(height: 48) - .contentShape(Rectangle()) - .gesture(WindowDragGesture()) - .allowsWindowActivationEvents(true) - } -``` - -```swift -Window("Welcome", id: "welcome") { - WelcomeView() -} -.windowStyle(.plain) -.defaultLaunchBehavior(.presented) -``` - -## Review Checklist - -- The scene type matches the window's role and lifecycle. -- Hidden titles still leave a meaningful logical title for accessibility and - menus. -- Toolbar background removal is intentional and does not hurt titlebar legibility - or window control placement. -- Windows with hidden or removed toolbars still have a reliable drag region and - support click-then-drag activation from the background. -- Utility windows have restoration/minimize behavior that matches their purpose. -- Restoration overrides are used only when a scene should intentionally differ - from the user's system-wide setting. -- Default and ideal placement use `content.sizeThatFits(.unspecified)` and - `context.defaultDisplay.visibleRect` when content/display size matters. -- Media windows preserve aspect ratio and fit on small or rotated displays. -- Borderless windows still have a usable move/drag affordance. - -## Guardrails - -- Do not use `.toolbar(removing: .title)` just to hide a title you forgot to set. - Keep the underlying window title meaningful. -- Do not hide the toolbar background or the whole toolbar without replacing the - lost drag affordance. -- Do not disable restoration on the main document/navigation window unless the - user explicitly wants a fresh-start app every launch. -- Do not hardcode one monitor size or assume a single-display setup when sizing - player windows. -- Do not reach for `NSWindow` mutation before checking whether - `.windowMinimizeBehavior`, `.restorationBehavior`, `.defaultWindowPlacement`, - `.windowIdealPlacement`, `.windowStyle`, or `.defaultLaunchBehavior` already - solve the problem. -- Do not leave a plain borderless window without any obvious drag or close path. - -## When To Use Other Skills - -- Use `swiftui-patterns` for broader scene, commands, settings, sidebar, - and inspector architecture. -- Use `liquid-glass` when the main question is modern macOS visual treatment, - Liquid Glass, or system material adoption. -- Use `appkit-interop` if a custom window behavior truly requires `NSWindow`, - `NSPanel`, or responder-chain control. -- Use `build-run-debug` to launch and verify the resulting windows. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2dbd36dfd..6807e918d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,28 +1,26 @@ - + ## Approved issue - Fixes # ## TL;DR - + ## What was happening - + ## What this changes - + ## Heads-up - + ## Tests diff --git a/AGENTS.md b/AGENTS.md index a30fce065..4c2a368b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,78 +1,69 @@ # AGENTS.md -Runway is a SwiftPM-based SwiftUI menu-bar app for macOS that shows AI provider usage widgets (Claude, Codex, Cursor, Grok, Devin, and more). +Runway is a SwiftPM SwiftUI menu-bar app for macOS. It shows usage widgets for AI providers (Claude, Codex, Cursor, Grok, Devin, and more). This file holds the engineering conventions. Read it before you contribute. -This file documents the engineering conventions for the project. Read it before you contribute. +AGENTS.md is the only place for agent instructions. CLAUDE.md contains `@AGENTS.md` and nothing else. -## Agent Instructions - -AGENTS.md is the source of truth for agent instructions in this repository. CLAUDE.md files must only point to the nearest AGENTS.md file with `@AGENTS.md`. Do not add guidance, duplicate instructions, or project rules to CLAUDE.md. - -> **Repository note:** This is the native Swift edition of Runway. Active development happens on the `main` branch. (NOT the legacy Tauri version which now sits in the `tauri-legacy` branch) - -## Releases - -`main` is the active development line. The NextByte-owned `mstallone/runway` fork ships via `.github/workflows/release.yml` (Sparkle appcast on `update-feed`). Cut releases with the release-swift skill. `docs/releasing.md` documents the secrets and the one-time setup. - -### Guardrails (do not break) -- Versions are `0.7.x` and up. Never reuse a `0.6.x` number — those are the original edition's released tags, now frozen on the `tauri-legacy` branch (final release `v0.6.28`). -- **Never increase the version number on your own initiative — always ask for explicit approval first.** The version is a deliberate owner decision: propose the number and wait for explicit sign-off before tagging or cutting a release. -- Releases use plain `vMAJOR.MINOR.PATCH` tags and become GitHub "Latest". Prerelease suffixes and Sparkle beta channels are not supported. -- The NextByte fork starts a fresh GitHub Release and Sparkle history. Do not carry forward the upstream fork's Tauri `latest.json`, GitHub Release assets, appcast entries, or signing identity. -- Never leave a release in Draft, and never ship blank notes: the release-swift skill generates the changelog and verifies the published release after every cut. +Active development happens on `main`. The old Tauri edition is frozen on the `tauri-legacy` branch. ## Architecture -- SwiftPM executable target; SwiftUI content hosted in an AppKit-owned `NSStatusItem` + custom key-capable `NSPanel`. +- SwiftPM executable target. SwiftUI content is hosted in an AppKit `NSStatusItem` and a custom key-capable `NSPanel`. - Swift 6 with strict concurrency. -- Providers implement the small `ProviderRuntime` protocol: an auth store reads credentials already on the user's machine, a usage client calls the provider's API, and a mapper normalizes the response into `MetricLine` values. The UI renders those normalized values. -- See `docs/` for behavior docs and the developer docs (architecture overview, adding a provider). +- Each provider implements `ProviderRuntime`: an auth store reads credentials already on the machine, a usage client calls the provider API, and a mapper turns the response into `MetricLine` values. The UI renders those values. +- `docs/` holds the behavior docs and the developer docs (architecture, adding a provider). ## Providers -Conventions for the per-provider modules under `Sources/Runway/Providers//`. +Provider modules live under `Sources/Runway/Providers//`. -- **Structure:** one folder per provider with an auth store (reads credentials already on the user's machine), a usage client (calls the provider API), and a mapper (normalizes to `MetricLine`). The module conforms to `ProviderRuntime`: `refresh()` plus `hasLocalCredentials()`. `hasLocalCredentials()` is the local-only credential probe. First-run detection (`FirstRunSeeder`) uses it, and new-provider detection (`NewProviderSeeder`) uses it on the first launch after the provider ships. Mirror the same local credential sources and usability filters that `refresh()` starts with. Reuse the auth-store loaders; do not add a second credential-reading path. See `docs/adding-a-provider.md` and `docs/provider-enablement.md`. -- **Model pricing:** all spend imputation (Claude, Codex, Cursor, Grok) prices through the shared engine in `Sources/Runway/Pricing/` (see `docs/pricing.md`). Cursor-native model rates and alias rules live in `Sources/Runway/Resources/pricing_supplement.json`. Sync new or changed models from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md): update `updated_at`, the pricing entries, and the `alias_rules` for CSV model slugs. A merge to `main` publishes the file to `update-feed`, so installed apps pick it up without a release. The bundled LiteLLM/models.dev snapshots regenerate with `script/update_pricing_snapshots.sh` (a release-time chore). -- **Default order:** Claude, Codex, Cursor first (the established providers, in that order), then every other provider alphabetically by display name (Antigravity, Devin, Grok, …). The order is the array order in `AppContainer`, which seeds `LayoutStore`'s default provider order (and `resetToDefault`). A new provider slots into the alphabetical tail. -- **Metric placement defaults:** when you add or change a metric, confirm its four defaults with the owner before you choose — never pick silently: - 1. enabled on/off (`DefaultLayout.metricIDs`), - 2. Always Visible vs. On Demand — above the fold vs. behind the per-provider caret (`DefaultLayout.expandedMetricIDs`). Note: a provider always keeps at least one Always Visible row. When every metric is marked On Demand, the dashboard promotes all of them, so a fully On Demand provider is not possible. Leave one metric Always Visible so the caret appears, +- **Structure.** One folder per provider: auth store, usage client, mapper. The module conforms to `ProviderRuntime` with `refresh()` and `hasLocalCredentials()`. `hasLocalCredentials()` is a local-only check. `FirstRunSeeder` calls it on a fresh install, and `NewProviderSeeder` calls it once when a new provider ships. It must check the same credential sources and usability filters that `refresh()` uses, through the same auth-store loaders. Do not add a second credential-reading path. See `docs/adding-a-provider.md` and `docs/provider-enablement.md`. +- **Pricing.** All spend estimates (Claude, Codex, Cursor, Grok) go through `Sources/Runway/Pricing/` (see `docs/pricing.md`). Cursor-native model rates and alias rules live in `Sources/Runway/Resources/pricing_supplement.json`. Sync new or changed models from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md): update `updated_at`, the pricing entries, and the `alias_rules`. A merge to `main` publishes the file to `update-feed`, so installed apps pick it up without a release. Regenerate the bundled LiteLLM and models.dev snapshots with `script/update_pricing_snapshots.sh` before a release. +- **Default order.** Claude, Codex, Cursor, then every other provider alphabetically by display name. The order is the array order in `AppContainer`, which seeds the default order in `LayoutStore`. +- **Metric defaults.** When you add or change a metric, confirm these four defaults with the owner. Never pick them yourself: + 1. enabled on or off (`DefaultLayout.metricIDs`), + 2. Always Visible or On Demand (`DefaultLayout.expandedMetricIDs`). Keep at least one metric Always Visible per provider. If every metric is On Demand, the dashboard promotes all of them and the caret disappears, 3. pinned to the menu bar (`DefaultLayout.pinnedMetricIDs`), - 4. order (within a provider, the `widgetDescriptors` declaration order). + 4. order within the provider (the `widgetDescriptors` declaration order). -## Running / Testing Changes +## Releases -- There is no hot reload. The app is a long-lived menu-bar process, so **every code change requires a full rebuild and restart of the running app** to take effect — kill the running instance, rebuild, and relaunch before you test. +Releases ship from `.github/workflows/release.yml` with a Sparkle appcast on `update-feed`. Cut them with the release-swift skill. `docs/releasing.md` covers the secrets and one-time setup. -## Pull Requests +- Versions are `0.7.x` and up. Never reuse a `0.6.x` number. Those belong to the Tauri edition (final release `v0.6.28`). +- Never bump the version on your own. Propose a number and wait for the owner's explicit approval before tagging or releasing. +- Tags are plain `vMAJOR.MINOR.PATCH` and become the GitHub "Latest" release. No prerelease suffixes and no Sparkle beta channel. +- Do not carry over the upstream fork's Tauri `latest.json`, release assets, appcast entries, or signing identity. +- Never leave a release in Draft or with empty notes. The release-swift skill writes the changelog and verifies the published release. -Every PR description must follow this structure so reviewers can skim it quickly: +## Testing changes -- **TL;DR** — open with a one- or two-sentence plain-English summary of the change. -- **What was happening** — plain-English bullet points describing the prior behavior, bug, or gap that motivated the change. -- **What this changes** — bullet points describing what the PR actually changes. -- **Heads-up** (optional) — noteworthy things a reviewer or future maintainer should consider (risks, follow-ups, trade-offs). -- **Tests** (optional) — how the change was verified. +There is no hot reload. Kill the running app, rebuild, and relaunch before you test a change. -## Documentation +## Pull requests -- Logic changes must update any docs in `docs/` that describe the affected behavior. -- Keep docs simple, less-technical, and easy to skim; exclude visual design details. +Every PR description uses this structure: -## Code Conventions +- **TL;DR**: one or two sentences. +- **What was happening**: the prior behavior, bug, or gap. +- **What this changes**: what the PR does. +- **Heads-up** (optional): risks, follow-ups, trade-offs. +- **Tests** (optional): how you verified it. + +## Documentation -- When you fix a bug, add a regression test where it fits. -- Keep files under ~500 LOC; split or refactor as needed. -- No new dependencies without justification. -- When you add a provider, follow the conventions in "## Providers". +- A logic change must update every page in `docs/` that describes the affected behavior. +- Keep docs simple and easy to skim. Describe behavior, not visual design. -## Error Handling +## Code -Always fail loudly into the local log file and show friendly errors to the user. Do not add silent fallbacks that hide real problems. Only validate at system boundaries (user input, external APIs); trust internal code and framework guarantees. +- Add a regression test when you fix a bug. +- Keep files under about 500 lines. +- No new dependencies without a reason. +- Fail loudly into the local log file and show a friendly error to the user. No silent fallbacks. Validate only at system boundaries (user input, external APIs). Trust internal code. ## UI -- Use title case for any hardcoded copy used as a title. -- Match the existing design language; Runway has a specific look and feel. -- Only add tooltips (`hoverTooltip`) when explicitly asked to. Don't add them proactively to new controls. +- Use title case for hardcoded titles. +- Match the existing design language. +- Add tooltips (`hoverTooltip`) only when asked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e89dc7fe..e6dc4e7ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,105 +1,89 @@ # Contributing to Runway -Runway accepts contributions through a strict, issue-first workflow, and the quality bar is deliberately high. **By design, most external pull requests are closed** — any that don't follow the rules below are closed without review. Read this entire document before you open a PR. +Runway uses an issue-first workflow. External pull requests must link an issue a maintainer has approved with the `approved` label. PRs without one are closed without review. Read this page before opening a PR. -## Philosophy +## Scope -Runway is highly opinionated. It focuses on clean design, fast performance, and a great user experience. The feature set is intentionally limited to core functionality: tracking AI coding subscription usage, nothing more. Contributions that try to expand that scope, add unnecessary complexity, or compromise the UX will be closed. +Runway tracks AI coding subscription usage. That is the whole feature set. It is opinionated about clean design, speed, and a simple UX. Changes that expand the scope, add complexity, or hurt the UX are closed. -If you're unsure whether your idea fits, open an issue first. External pull requests without a linked, maintainer-approved issue are closed without review. +If you are unsure whether an idea fits, open an issue first. -## Ground Rules +## Rules -- **Open an approved issue first.** External PRs must link an open issue a maintainer has approved with the `approved` label. No approved issue, no review. -- **Most external PRs get closed, by design.** It's not personal — it keeps a small, focused project sane. See the Pull Request Policy below. -- No feature creep. If it's not about usage tracking, it doesn't belong here. -- No AI-generated commit messages. Write your own. -- Test your changes, and say how you tested them in the PR. -- Keep it simple. Don't over-engineer. -- One PR per concern. Don't bundle unrelated changes. -- Match the existing design language. Runway has a specific look and feel — [AGENTS.md](AGENTS.md) documents the display conventions. +- **Get an issue approved first.** External PRs must link an open issue labeled `approved`. +- **Keep PRs under 1,000 changed lines.** Split larger work. +- **One concern per PR.** Do not bundle unrelated changes. +- **Write your own commit messages.** No AI-generated commit messages. +- **Test your change and say how in the PR.** +- **Keep it simple.** Do not over-engineer. +- **Match the existing design language.** [AGENTS.md](AGENTS.md) documents the conventions. -## Pull Request Policy +Closures are not personal and are reversible. Get the issue approved or fix the problem, then reopen or open a focused replacement. Maintainers and collaborators can open PRs directly. -External pull requests are **closed without review** if they: +By submitting a pull request you agree your contribution is licensed under the [MIT License](LICENSE). -- **Have no approved issue** — they don't link an open issue labeled `approved`. -- **Are too large** — they change more than 1,000 lines. Split the work into smaller PRs. +## Workflow -Closures aren't personal and are reversible: get the issue approved (or fix the problem), then reopen or open a focused replacement. Maintainers and collaborators can open PRs directly. +1. Open an issue and wait for the `approved` label. +2. Fork the repo and create a branch (`feat/my-change`, `fix/some-bug`). +3. Make only the approved change. +4. Run `swift build` and `swift test`. +5. Open a PR against `main` with `Fixes #` and the PR description structure from [AGENTS.md](AGENTS.md). -## License Agreement +### Adding a provider -By submitting a pull request, you agree that your contribution is licensed under the [MIT License](LICENSE) that covers this project. +A provider is a small Swift module under `Sources/Runway/Providers//` that conforms to `ProviderRuntime`: an auth store reads credentials already on the machine, a usage client calls the provider API, and a mapper turns the response into metric lines. See [docs/adding-a-provider.md](docs/adding-a-provider.md) and [docs/architecture.md](docs/architecture.md). -## How to Contribute +1. Open an issue and get it approved. Say why the provider fits and how its usage data is accessible. +2. Create `Sources/Runway/Providers//` and implement `ProviderRuntime`. +3. Register the provider in `AppContainer`. +4. Add tests under `Tests/RunwayTests/`. +5. Add a page in `docs/providers/` (metrics, credential sources, endpoints, troubleshooting). +6. Test it with `./script/build_and_run.sh`. +7. Open a PR that says how you verified it. -### Fork and PR workflow +You can also [request a provider](https://github.com/mstallone/runway/issues/new?template=new_provider.yml) without building it. -1. Open an issue that describes the change, and wait for a maintainer to approve it with the `approved` label -2. Fork the repo -3. Create a branch (`feat/my-change`, `fix/some-bug`, etc.) -4. Make only the approved change -5. Run `swift build` and `swift test` to verify nothing is broken -6. Open a PR against `main` and link the approved issue with `Fixes #` +### Fixing a bug -### Add a provider +Reference the approved issue, describe the root cause and fix, and add a regression test where it fits. -Each provider is a small Swift module under `Sources/Runway/Providers//` that conforms to `ProviderRuntime`: an auth store reads credentials already on the user's machine, a usage client calls the provider's API, and a mapper normalizes the response into metric lines. See [docs/adding-a-provider.md](docs/adding-a-provider.md) for the full walkthrough (and [docs/architecture.md](docs/architecture.md) for how the pieces fit together). +### Requesting a feature -1. Open an issue and get it approved (`approved` label) — include why the provider fits and how its usage data is accessible -2. Create `Sources/Runway/Providers//` and implement `ProviderRuntime` -3. Register the provider in `AppContainer` -4. Add focused tests under `Tests/RunwayTests/` -5. Add a provider page in `docs/providers/` (metrics, credential sources, endpoints, troubleshooting) -6. Test it locally with `./script/build_and_run.sh` -7. Open a PR that describes how you verified the provider works +[Open an issue](https://github.com/mstallone/runway/issues/new?template=feature_request.yml) and wait for the `approved` label before writing code. -You can also [open an issue](https://github.com/mstallone/runway/issues/new?template=new_provider.yml) to request a provider without building it yourself. - -### Fix a bug - -1. Reference the approved issue number in your PR -2. Describe the root cause and fix -3. Add a regression test if applicable - -### Request a feature - -Don't open a PR for a feature without an approved issue first. [Open an issue](https://github.com/mstallone/runway/issues/new?template=feature_request.yml), make your case, and wait for the `approved` label. - -## What Gets Accepted +## What gets accepted - Bug fixes with clear descriptions -- New providers that follow the existing provider architecture +- New providers that follow the existing provider structure - Documentation improvements - Performance improvements with benchmarks - Accessibility improvements -## What Gets Rejected +## What gets rejected -- External PRs without an approved issue (closed without review) +- External PRs without an approved issue - PRs over 1,000 lines, or that bundle unrelated changes -- Features that expand the scope beyond usage tracking -- Changes that compromise speed, simplicity, or the existing UX +- Features outside usage tracking +- Changes that hurt speed, simplicity, or the existing UX - PRs without testing evidence -- Code with no clear purpose or explanation +- Code with no clear purpose - Cosmetic-only changes without prior discussion -## Code Standards +## Code standards - Swift 6 with strict concurrency, built with SwiftPM (no Xcode project) -- Follow existing patterns in the codebase — [AGENTS.md](AGENTS.md) is the engineering contract -- User-visible behavior changes must update the matching `docs/` page(s) in the same PR -- UI copy is plain language and sentence case -- No new dependencies without justification +- Follow existing patterns. [AGENTS.md](AGENTS.md) is the engineering contract. +- User-visible behavior changes must update the matching `docs/` page in the same PR. +- UI copy is plain language and sentence case. +- No new dependencies without a reason. ## Maintainers - [@mstallone](https://github.com/mstallone) (owner) -All PRs require maintainer approval before they merge. -Only the owner, [@mstallone](https://github.com/mstallone), can create release tags (`v*`). +All PRs need maintainer approval to merge. Only the owner can create release tags (`v*`). -## Questions? +## Questions -Open a [bug report](https://github.com/mstallone/runway/issues/new?template=bug_report.yml) or [feature request](https://github.com/mstallone/runway/issues/new?template=feature_request.yml) using the issue templates. +Open a [bug report](https://github.com/mstallone/runway/issues/new?template=bug_report.yml) or [feature request](https://github.com/mstallone/runway/issues/new?template=feature_request.yml). diff --git a/README.md b/README.md index fd17cb092..1faa7b86d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Runway -Fast, observable AI usage across every provider and account, right from the macOS menu bar. +AI usage across every provider and account, in the macOS menu bar. **Website:** [runway.page](https://runway.page/) -Runway brings multiple accounts across Claude, Codex, Cursor, Grok, Devin, and more into one focused view of limits, credits, and spend. Cached data appears instantly, live refreshes stay out of the way, and the metrics you care about can sit directly in the menu bar. +Runway shows limits, credits, and spend for Claude, Codex, Cursor, Grok, Devin, and more in one place. Cached data appears instantly, refreshes run in the background, and the metrics you care about can sit in the menu bar.

Runway hero: menu bar pins and the dashboard popover with the Total Spend ring plus Claude and Codex meters in normal, warning, and critical states @@ -12,62 +12,57 @@ Runway brings multiple accounts across Claude, Codex, Cursor, Grok, Devin, and m ## Installation -**Direct download:** download the latest universal DMG from the [releases page](https://github.com/mstallone/runway/releases/latest). Open it. Drag Runway to your Applications folder. +Download the latest universal DMG from the [releases page](https://github.com/mstallone/runway/releases/latest), open it, and drag Runway to Applications. -The app updates itself in place via signed, notarized [Sparkle](docs/updates.md) updates. Requires macOS 15 (Sequoia) or later. +The app updates itself through signed, notarized [Sparkle](docs/updates.md) updates. Requires macOS 15 (Sequoia) or later. ## Performance -Runway is a rebuilt-for-speed fork. We measured it against upstream OpenUsage on the same machine, -with the same providers and the same session-log corpus. It is faster on every axis we track — see -the details and methodology in [docs/performance.md](docs/performance.md). +Runway is a fork of OpenUsage rebuilt for speed. We measured both on the same machine with the same providers and session logs. See [docs/performance.md](docs/performance.md) for the method. | | OpenUsage | Runway | |---|---|---| -| Launch → menu bar icon | 5.4 s | **0.29 s** | -| Popup open → first frame | 61 ms | **23 ms** | +| Launch to menu bar icon | 5.4 s | **0.29 s** | +| Popover open to first frame | 61 ms | **23 ms** | | First open after launch | 2.59 s | **44 ms** | | One refresh pass | 8.6 s | **2.3 s** | | Memory (steady / peak) | 1.09 GB / 2.6 GB | **238 MB / 322 MB** | | First-launch scan | 165 s CPU, 12.9 GB peak | **12.7 s CPU, 1.7 GB peak** | -## Supported Providers +## Supported providers -- **[Antigravity](docs/providers/antigravity.md)** — shared Gemini and Claude pool quotas, 5-hour and weekly windows -- **[Claude](docs/providers/claude.md)** — session, weekly, model-specific limits, extra usage, local daily spend -- **[Codex](docs/providers/codex.md)** — session, weekly, credits, local daily spend -- **[Copilot](docs/providers/copilot.md)** — AI credits, extra usage, organization billing, chat and completions -- **[Cursor](docs/providers/cursor.md)** — credits, total usage, Grok Bot, Cursor Models, Other Models, requests, on-demand, per-day spend -- **[Devin](docs/providers/devin.md)** — weekly and daily quota, extra usage balance -- **[Grok](docs/providers/grok.md)** — weekly shared pool, pay-as-you-go, local daily spend -- **[Kimi](docs/providers/kimi.md)** — five-hour and weekly Kimi Code quota, Extra Usage balance and monthly spend -- **[Muse](docs/providers/muse.md)** — five-hour and weekly Muse Code subscription quota -- **[OpenCode](docs/providers/opencode.md)** — Go session/weekly/monthly caps, Zen spend, local daily spend -- **[OpenRouter](docs/providers/openrouter.md)** — credit balance, daily/weekly/monthly spend (API key) -- **[Sakana Fugu](docs/providers/sakana.md)** — subscription quota plus local Fugu Ultra usage trend and estimated API-rate value -- **[Z.ai](docs/providers/zai.md)** — session, weekly, web-search quotas (GLM Coding Plan, API key) +- **[Antigravity](docs/providers/antigravity.md)**: shared Gemini and Claude pool quotas, 5-hour and weekly windows +- **[Claude](docs/providers/claude.md)**: session, weekly, model-specific limits, extra usage, local daily spend +- **[Codex](docs/providers/codex.md)**: session, weekly, credits, local daily spend +- **[Copilot](docs/providers/copilot.md)**: AI credits, extra usage, organization billing, chat and completions +- **[Cursor](docs/providers/cursor.md)**: credits, total usage, Grok Bot, Cursor Models, Other Models, requests, on-demand, per-day spend +- **[Devin](docs/providers/devin.md)**: weekly and daily quota, extra usage balance +- **[Grok](docs/providers/grok.md)**: weekly shared pool, pay-as-you-go, local daily spend +- **[Kimi](docs/providers/kimi.md)**: five-hour and weekly Kimi Code quota, Extra Usage balance and monthly spend +- **[Muse](docs/providers/muse.md)**: five-hour and weekly Muse Code subscription quota +- **[OpenCode](docs/providers/opencode.md)**: Go session, weekly, and monthly caps, Zen spend, local daily spend +- **[OpenRouter](docs/providers/openrouter.md)**: credit balance, daily, weekly, and monthly spend (API key) +- **[Sakana Fugu](docs/providers/sakana.md)**: subscription quota plus local Fugu Ultra usage trend and estimated API-rate value +- **[Z.ai](docs/providers/zai.md)**: session, weekly, and web-search quotas (GLM Coding Plan, API key) -Most providers read the credentials already on your machine (keychain, auth files, app state) — no extra login. OpenRouter and Z.ai are the exceptions: they have no local credential to reuse, so you supply an API key (see [OpenRouter setup](docs/providers/openrouter.md) or [Z.ai setup](docs/providers/zai.md)). Runway uses credentials only for the corresponding provider requests. Runway collects no product analytics or usage statistics. The [Privacy](docs/privacy.md) page documents public pricing downloads and optional iCloud sync. +Most providers read the credentials already on your Mac (keychain, auth files, app state). OpenRouter and Z.ai have no local credential to reuse, so you supply an API key (see [OpenRouter setup](docs/providers/openrouter.md) or [Z.ai setup](docs/providers/zai.md)). Runway uses each credential only for that provider's requests. Runway collects no analytics. The [Privacy](docs/privacy.md) page covers the public pricing downloads and optional iCloud sync. ## Features -- **Menu bar pins.** Pin metrics to the menu bar (up to 2 per provider); render as compact text or mini bars. The strip hides metrics with no data instead of showing placeholders. -- **Dashboard popover.** Provider-grouped meters with live reset countdowns and pace indicators. Click usage or reset values to flip their display everywhere; right-click a row to hide or star it, refresh its provider, or open Customize. -- **Global shortcut.** Toggle the popover from anywhere — record any combo in Settings. -- **Customize.** Turn providers and metrics on or off, choose which rows stay Always Visible or On Demand, and drag-reorder both. -- **Stale-while-revalidate.** Cached values display instantly at launch; refresh runs every 5 minutes. -- **[One-shot CLI](docs/cli.md).** Agents can read stable limit JSON through the same five-minute cache with `runway`, or bypass freshness with `runway --force`; the menu-bar app does not need to be running. -- **[Local HTTP API](docs/local-http-api.md).** Other apps can read machine-friendly limits from `127.0.0.1:6736/v1/limits`; the legacy `/v1/usage` UI contract remains supported. It is loopback-only and never serves credentials; note that browser pages can read it too — see the [privacy note](docs/local-http-api.md#cors-and-privacy). +- **Menu bar pins.** Pin up to two metrics per provider to the menu bar, as text or mini bars. Metrics with no data are hidden. +- **Dashboard popover.** Meters grouped by provider, with live reset countdowns and pace indicators. Click a usage or reset value to change how it displays everywhere. Right-click a row to hide or star it, refresh its provider, or open Customize. +- **Global shortcut.** Toggle the popover from anywhere. Record any combo in Settings. +- **Customize.** Turn providers and metrics on or off, choose which rows are Always Visible or On Demand, and drag to reorder. +- **Cache first.** Cached values show instantly at launch. Refresh runs every 5 minutes. +- **[CLI](docs/cli.md).** `runway` prints limit JSON from the same five-minute cache. `runway --force` refreshes first. The app does not need to be running. +- **[Local HTTP API](docs/local-http-api.md).** Other apps can read limits from `127.0.0.1:6736/v1/limits`. The older `/v1/usage` route still works. Loopback only, never serves credentials. Browser pages can read it too. See the [privacy note](docs/local-http-api.md#cors-and-privacy). - **[Proxy support](docs/proxy.md).** Route provider requests through SOCKS5 or HTTP(S) via `~/.runway/config.json`. -- **Native settings.** Launch at login, global shortcut, icon style, theme, 12/24-hour time — see [Settings](docs/settings.md). -- **[Automatic updates](docs/updates.md).** Signed, notarized stable updates via Sparkle. +- **Native settings.** Launch at login, global shortcut, icon style, theme, 12/24-hour time. See [Settings](docs/settings.md). +- **[Automatic updates](docs/updates.md).** Signed, notarized updates via Sparkle. -## iPhone Companion +## iPhone companion -Runway for iOS mirrors combined usage from every Mac you run — spend today, yesterday, and over the -last 30 days. The data syncs privately over iCloud. The app adds lock screen and home screen -widgets. Each widget can show cost or tokens; see the [iOS app](docs/ios-app.md) for what it shows -and how syncing works. +Runway for iOS shows combined usage from every Mac you run: spend today, yesterday, and over the last 30 days. Data syncs privately over iCloud. Lock screen and home screen widgets can show cost or tokens. See the [iOS app](docs/ios-app.md).

Runway lock screen widgets showing today's AI spend synced from your Macs @@ -77,36 +72,32 @@ and how syncing works. ## Documentation -Behavior docs live in [docs/](docs/README.md): the [dashboard](docs/dashboard.md), [menu bar pins](docs/menu-bar.md), [settings](docs/settings.md), [refresh & caching](docs/refreshing.md), the [CLI](docs/cli.md), the [local HTTP API](docs/local-http-api.md), the [proxy](docs/proxy.md), and one page per provider. +Behavior docs live in [docs/](docs/README.md): the [dashboard](docs/dashboard.md), [menu bar pins](docs/menu-bar.md), [settings](docs/settings.md), [refresh and caching](docs/refreshing.md), the [CLI](docs/cli.md), the [local HTTP API](docs/local-http-api.md), the [proxy](docs/proxy.md), and one page per provider. -For working on the code, see the developer docs: [architecture](docs/architecture.md), [adding a provider](docs/adding-a-provider.md), and [debugging & capturing logs](docs/debugging.md). +For working on the code: [architecture](docs/architecture.md), [adding a provider](docs/adding-a-provider.md), and [debugging and logs](docs/debugging.md). ## Requirements - macOS 15 (Sequoia) or later -- Universal binary — runs natively on both Apple Silicon and Intel Macs - -Runway computes the Today / Yesterday / Last 30 Days spend tiles natively from local CLI logs -(Claude, Codex, Grok, and Sakana Fugu) or Cursor's usage export — no Node.js or other runtime -needed. It estimates dollars with [model pricing](docs/pricing.md). - +- Universal binary for Apple Silicon and Intel +Runway computes the Today, Yesterday, and Last 30 Days spend tiles from local CLI logs (Claude, Codex, Grok, Sakana Fugu) or Cursor's usage export. No Node.js or other runtime is needed. Dollars are estimated with [model pricing](docs/pricing.md). ## Development ```sh -swift build # debug build -swift test # run the test suite +swift build # debug build +swift test # run the test suite ./script/build_and_run.sh # build and launch the dev app from dist/ (no install) ``` -SwiftPM package, SwiftUI content hosted in an AppKit-owned `NSStatusItem` + custom key-capable `NSPanel`, Swift 6 strict concurrency. The app and CLI share one module: providers implement a small `ProviderRuntime` protocol (auth store → usage client → mapper → `ProviderSnapshot`), and both surfaces read the same normalized data. See the [architecture overview](docs/architecture.md) for how the pieces fit together, and [AGENTS.md](AGENTS.md) for the engineering conventions. +Runway is a SwiftPM package: SwiftUI content hosted in an AppKit `NSStatusItem` and a custom key-capable `NSPanel`, Swift 6 strict concurrency. The app and CLI share one module. Providers implement a small `ProviderRuntime` protocol (auth store, usage client, mapper, `ProviderSnapshot`), and both surfaces read the same normalized data. See the [architecture overview](docs/architecture.md) and [AGENTS.md](AGENTS.md) for the conventions. -Releases are automated: when you push a stable tag on `main`, the pipeline tests, builds, signs, notarizes, and publishes the release. The pipeline and its one-time setup are documented in [Releasing](docs/releasing.md). +Releases are automated. Pushing a stable tag on `main` tests, builds, signs, notarizes, and publishes the release. See [Releasing](docs/releasing.md). ## Contributing -Issues are welcome. Pull requests are **strict and issue-first**: external PRs must link an issue a maintainer has approved with the `approved` label — **most external PRs without one are closed by design**. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening one. Report security issues privately per [SECURITY.md](SECURITY.md). The Runway name and logo are covered by the [trademark policy](TRADEMARK.md). +Issues are welcome. Pull requests are issue-first: an external PR must link an issue a maintainer has approved with the `approved` label, or it is closed. Read [CONTRIBUTING.md](CONTRIBUTING.md) first. Report security issues privately per [SECURITY.md](SECURITY.md). The Runway name and logo are covered by the [trademark policy](TRADEMARK.md). ## License diff --git a/SECURITY.md b/SECURITY.md index 16cbbe7e5..3d52bd8cf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,24 +1,17 @@ # Security Policy -## Reporting a Vulnerability +## Reporting a vulnerability -If you find a security vulnerability in Runway, please report it responsibly. Do not open a public issue. +Do not open a public issue. Open a [private vulnerability report](https://github.com/mstallone/runway/security/advisories/new) on GitHub instead. The report stays private until a fix is released. -### GitHub Security Advisories +Include: -1. Open a [private vulnerability report](https://github.com/mstallone/runway/security/advisories/new) -2. Fill in the details and submit the report - -This keeps the report private until a fix is released. - -## What to Include - -- Description of the vulnerability +- A description of the vulnerability - Steps to reproduce - Affected versions -- Impact assessment (what can an attacker do?) +- Impact (what can an attacker do?) -## Response Timeline +## Response timeline - Acknowledgment within 48 hours - Assessment and plan within 7 days @@ -26,19 +19,19 @@ This keeps the report private until a fix is released. ## Scope -The following are in scope: +In scope: - The Runway desktop application - The built-in providers (credential handling, API calls) - The local HTTP API - Build and release infrastructure -The following are out of scope: +Out of scope: -- Third-party provider APIs (report to the provider directly) -- Social engineering attacks -- Denial of service attacks +- Third-party provider APIs (report to the provider) +- Social engineering +- Denial of service -## Supported Versions +## Supported versions -Only the latest release is supported with security updates. +Only the latest release receives security updates. diff --git a/TRADEMARK.md b/TRADEMARK.md index e80023c66..da833ee8a 100644 --- a/TRADEMARK.md +++ b/TRADEMARK.md @@ -1,13 +1,13 @@ # Trademark Policy -## The Runway Brand +## The Runway brand -This fork is independently branded as Runway and uses its own name, icon, and visual identity. +Runway is independently branded. It uses its own name, icon, and visual identity. -## Upstream Brand +## Upstream brand -Runway is derived from the original OpenUsage project. The "OpenUsage" name, logo, and associated visual identity remain trademarks of Robin Ebers. The MIT license covers the upstream source code, not that upstream brand. +Runway is derived from the OpenUsage project. The "OpenUsage" name, logo, and visual identity are trademarks of Robin Ebers. The MIT license covers the upstream source code, not the upstream brand. -Runway is not endorsed by, affiliated with, or an official part of the original OpenUsage project. +Runway is not endorsed by, affiliated with, or part of the OpenUsage project. -Questions about the OpenUsage brand should be directed to [rob@robinebers.com](mailto:rob@robinebers.com). +Questions about the OpenUsage brand go to [rob@robinebers.com](mailto:rob@robinebers.com). diff --git a/docs/README.md b/docs/README.md index d5dcde328..961dce966 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,29 +1,29 @@ # Runway Documentation -What the app does and how it behaves. These pages describe **behavior, not visuals**. We update these pages together with any change to that behavior — if the app and a page here disagree, that's a bug. +What the app does and how it behaves. These pages describe behavior, not visuals. If the app and a page disagree, that is a bug. ## The app -- [Dashboard](dashboard.md) — the popover: rows, toggles, reordering, keyboard shortcuts -- [Menu bar](menu-bar.md) — pinning metrics into the menu bar -- [Settings](settings.md) — every option, what it changes -- [Memory Explorer](memory-explorer.md) — view and edit each AI harness's memory and instruction files -- [Refreshing & caching](refreshing.md) — when data updates and what happens when a fetch fails -- [iCloud Sync](icloud-sync.md) — how spend history is combined across Macs -- [iOS Companion App](ios-app.md) — the iPhone/iPad viewer for synced usage -- [Model pricing](pricing.md) — how spend tiles price tokens, and where the rates come from -- [Updates](updates.md) — automatic updates and manual checks -- [Privacy](privacy.md) — what stays local and which optional services can send data +- [Dashboard](dashboard.md): the popover, rows, toggles, reordering, keyboard shortcuts +- [Menu bar](menu-bar.md): pinning metrics to the menu bar +- [Settings](settings.md): every option and what it changes +- [Memory Explorer](memory-explorer.md): view and edit each AI harness's memory and instruction files +- [Refreshing and caching](refreshing.md): when data updates and what happens when a fetch fails +- [iCloud Sync](icloud-sync.md): how spend history is combined across Macs +- [iOS companion app](ios-app.md): the iPhone and iPad viewer for synced usage +- [Model pricing](pricing.md): how spend tiles price tokens and where the rates come from +- [Updates](updates.md): automatic updates and manual checks +- [Privacy](privacy.md): what stays local and which optional services send data ## Integrations -- [Command-line interface](cli.md) — one-shot cached and forced usage reads for agents and scripts -- [Local HTTP API](local-http-api.md) — read your usage from other apps on `127.0.0.1:6736` -- [Proxy](proxy.md) — route provider requests through SOCKS5 or HTTP(S) +- [Command-line interface](cli.md): one-shot usage reads for agents and scripts +- [Local HTTP API](local-http-api.md): read usage from other apps on `127.0.0.1:6736` +- [Proxy](proxy.md): route provider requests through SOCKS5 or HTTP(S) ## Providers -What each provider tracks, where its credentials come from, and what to do when it shows an error. +What each provider tracks, where its credentials come from, and what its errors mean. - [Antigravity](providers/antigravity.md) - [Claude](providers/claude.md) @@ -41,10 +41,8 @@ What each provider tracks, where its credentials come from, and what to do when ## For developers -How the app is built and how to extend it. - -- [Architecture](architecture.md) — composition root, stores, the provider pipeline, the AppKit bridge -- [Adding a provider](adding-a-provider.md) — the metric contract and the register/test/document steps -- [Debugging & capturing logs](debugging.md) — running a local build and streaming logs -- [Logging](logging.md) — the file log, log levels, subsystem tags, and what is never logged -- [Releasing](releasing.md) — the automated release pipeline and its one-time signing/TestFlight setup (maintainer-only) +- [Architecture](architecture.md): composition root, stores, the provider pipeline, the AppKit bridge +- [Adding a provider](adding-a-provider.md): the metric contract and the register, test, and document steps +- [Debugging and logs](debugging.md): running a local build and streaming logs +- [Logging](logging.md): the file log, log levels, subsystem tags, and what is never logged +- [Releasing](releasing.md): the release pipeline and its one-time setup (maintainer only) diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 129acf57b..8802256ea 100644 --- a/docs/adding-a-provider.md +++ b/docs/adding-a-provider.md @@ -1,93 +1,57 @@ # Adding a Provider -How to add a new AI provider to Runway. Read the [architecture overview](architecture.md) first so the -pieces below make sense. +How to add a new AI provider to Runway. Read the [architecture overview](architecture.md) first. ## What a provider is -A provider is a small Swift module under `Sources/Runway/Providers//` that conforms to -`ProviderRuntime`. It has three parts: +A provider is a small Swift module under `Sources/Runway/Providers//` that conforms to `ProviderRuntime`. It has three parts: - an **auth store** that reads credentials already on the user's machine (config files, keychain), - a **usage client** that calls the provider's API, -- a **mapper** that turns the response into the app's metric vocabulary. +- a **mapper** that turns the response into metric lines. -Runway never asks the user to paste a token — if the provider's own CLI or app has already logged in, -Runway reads those existing credentials. +Runway never asks the user to paste a token. If the provider's own CLI or app is logged in, Runway reads that login. -Besides `refresh()`, every provider implements `hasLocalCredentials()` — a cheap, local-only check -(files, keychain; never the network) for whether those credentials exist at all. A fresh install probes -it once to turn on exactly the providers the user actually has (see `FirstRunSeeder`). Existing -installs probe it once on the first launch after your provider ships (see `NewProviderSeeder`). A -correct implementation is what gets the new provider auto-enabled for the users who actually have the -tool (see [Which Providers Are On](provider-enablement.md)). Mirror the same credential sources -`refresh()` reads, and run blocking loads via `loadOffMainActor`. +Besides `refresh()`, every provider implements `hasLocalCredentials()`: a cheap, local-only check (files, keychain, never the network) for whether credentials exist. A fresh install calls it once to turn on the providers the user has (`FirstRunSeeder`). Existing installs call it once on the first launch after your provider ships (`NewProviderSeeder`). See [Which Providers Are On](provider-enablement.md). Check the same credential sources `refresh()` reads, and run blocking loads via `loadOffMainActor`. ## The metric contract -`refresh()` returns a `ProviderSnapshot` whose `lines` are `MetricLine` values. Pick the case by the shape -of the number, not by the provider: +`refresh()` returns a `ProviderSnapshot` whose `lines` are `MetricLine` values. Pick the case by the shape of the number: -- **`.progress`** — a bounded meter with `used`, `limit`, and a `format`: +- **`.progress`**: a bounded meter with `used`, `limit`, and a `format`: - `.percent` for quota-style limits (session, weekly), - - `.dollars` for a capped dollar amount (credits with a ceiling), - - `.count(suffix:)` for a capped count (e.g. requests per cycle). + - `.dollars` for a capped dollar amount, + - `.count(suffix:)` for a capped count (requests per cycle). - Add `resetsAt` when the window resets at a known time, and `periodDurationMs` for the cycle length. -- **`.values`** — an unbounded row carrying one or more raw numbers (each a `MetricValue`: a number, its - kind, an optional unit label like `"tokens"`). Use it for any limitless numeric row — a spend day carries - dollars *and* tokens, Codex credits carry dollars *and* a count. The widget picks which to show - (cost-only, tokens-only, or both) via its descriptor, and formatting happens at the display edge, so the - menu bar never re-parses a string. Prefer this for numbers. -- **`.badge`** — a short status pill, like `Disabled` or a pay-as-you-go cap. Use it for state rather than - a fillable number. -- **`.chart`** — dated numeric points for a compact usage-trend row. -- **`.text`** — a string-valued provider notice preserved in the local API. It does not render a widget; - use `.progress`, `.values`, `.badge`, or `.chart` for every descriptor-backed row. - -Set the snapshot's `plan` when the provider exposes a plan name. On failure, return -`ProviderSnapshot.error(provider:error:)` with a typed provider error when possible, so its friendly -description reaches the user. Use the message-only factory only when there is no typed error, and never -return stale or empty data silently. +- **`.values`**: an unbounded row with one or more raw numbers. Each is a `MetricValue`: a number, its kind, and an optional unit label like `"tokens"`. Use it for any limitless numeric row. A spend day carries dollars and tokens; Codex credits carry dollars and a count. The widget picks what to show (cost, tokens, or both) through its descriptor, and formatting happens at the display edge, so the menu bar never re-parses a string. +- **`.badge`**: a short status pill, like `Disabled` or a pay-as-you-go cap. Use it for state, not a number. +- **`.chart`**: dated numeric points for a usage-trend row. +- **`.text`**: a string notice preserved in the local API. It does not render a widget. Use `.progress`, `.values`, `.badge`, or `.chart` for every descriptor-backed row. + +Set the snapshot's `plan` when the provider exposes a plan name. On failure, return `ProviderSnapshot.error(provider:error:)` with a typed provider error when possible, so its friendly description reaches the user. Use the message-only factory only when there is no typed error. Never return stale or empty data silently. ## Steps -1. **Check first.** Look at open issues and `docs/providers/` to see if the provider is already requested - or in progress. -2. **Create the module.** Add `Sources/Runway/Providers//` with the auth store, usage client, and - mapper, conforming to `ProviderRuntime` — both `refresh()` and `hasLocalCredentials()` (the compiler - enforces the latter; there is no default). The probe must stay local-only and reuse the same auth-store - loaders and credential-usability filters that `refresh()` starts with — don't write a second - credential-reading path. Reuse the shared helpers in `Support/` (`ProviderParse` for - JSON/number/percent parsing, `RunwayISO8601` for timestamps) instead of copying them. -3. **Declare its widgets.** Expose the provider's metrics as `WidgetDescriptor`s using the factories in - `WidgetDescriptor+Factories.swift` (`percent`, `boundedDollars`, `boundedCount`, `spendTiles`, `dollarBalance`, `combined`, `values`, `badge`, and so on). +1. **Check first.** Look at open issues and `docs/providers/` to see if the provider is already requested or in progress. +2. **Create the module.** Add `Sources/Runway/Providers//` with the auth store, usage client, and mapper. Implement both `refresh()` and `hasLocalCredentials()` (there is no default). The probe must stay local-only and reuse the same auth-store loaders and usability filters that `refresh()` uses. Do not write a second credential-reading path. Reuse the helpers in `Support/` (`ProviderParse` for JSON, number, and percent parsing, `RunwayISO8601` for timestamps). +3. **Declare its widgets.** Expose the provider's metrics as `WidgetDescriptor`s using the factories in `WidgetDescriptor+Factories.swift` (`percent`, `boundedDollars`, `boundedCount`, `spendTiles`, `dollarBalance`, `combined`, `values`, `badge`, and so on). 4. **Register it.** Add the provider to the list in `AppContainer`. -5. **Test it.** Add focused tests under `Tests/RunwayTests/`, including a mapper test that feeds a - sample API response and checks the resulting metric lines. -6. **Document it.** Add a page under `docs/providers/` covering what it tracks, where its credentials come - from, the endpoints it calls, and what its error states mean. +5. **Test it.** Add tests under `Tests/RunwayTests/`, including a mapper test that feeds a sample API response and checks the resulting metric lines. +6. **Document it.** Add a page under `docs/providers/` covering what it tracks, where its credentials come from, the endpoints it calls, and what its error states mean. 7. **Run it.** Build and launch with `./script/build_and_run.sh` and confirm the provider shows up. ## Conventions -- Validate only at the boundary (the API response); trust the app's internal types. -- Match the metric labels and units the provider's own dashboard uses, so numbers are recognizable. -- Declare the provider's **quick links** on its `Provider` value (`links:`). Each link is a `ProviderLink(label:url:)` rendered as a button in the card's expanded area that opens the URL in the default browser. Ship the provider's own Status / Console / Dashboard pages where they exist; leave `links` off (it defaults to empty) for providers without any. Cap at **two** links per provider (standard labels: Status, Dashboard, API Keys, or Usage). Only `http(s)` URLs with a non-empty label render. +- Validate only at the boundary (the API response). Trust the app's internal types. +- Match the metric labels and units the provider's own dashboard uses. +- Declare the provider's quick links on its `Provider` value (`links:`). Each `ProviderLink(label:url:)` renders as a button in the card's expanded area and opens in the default browser. Ship the provider's Status, Console, or Dashboard pages where they exist. Leave `links` off for providers without any. At most two links per provider, with standard labels (Status, Dashboard, API Keys, or Usage). Only `http(s)` URLs with a non-empty label render. ## User-supplied API keys -Most providers read credentials already on the machine (a companion CLI/app's session, the keychain). -A provider with nothing local to read — OpenRouter is the first — conforms to `APIKeyManaging` so the -in-app **Settings → API Keys** card manages its key with no per-provider UI work: - -- The auth store exposes a four-state `keyStatus()` (`notSet` / `fromEnvironment` / `saved` / - `overrideActive`), a `currentAPIKey()` for the reveal toggle, and `saveAPIKey(_:)` / `deleteAPIKey()` - that write to a config file the auth store already reads. Config-file precedence over the env var is - what makes a saved key an override for free. -- The provider conforms by delegating those to its auth store, and reports its storage path and env - name for the card's copy. -- `AppContainer` collects every `APIKeyManaging` provider into `apiKeyProviders`, which the card - lists. Add the provider to the registry as usual and the card picks it up. - -Persist the key to a file the auth store already checks (don't introduce a parallel store), so the -file remains the source of truth and a user can still edit it by hand. +A provider with nothing local to read (OpenRouter, Z.ai) conforms to `APIKeyManaging` so the **Settings → API Keys** card manages its key with no per-provider UI work: + +- The auth store exposes a four-state `keyStatus()` (`notSet`, `fromEnvironment`, `saved`, `overrideActive`), a `currentAPIKey()` for the reveal toggle, and `saveAPIKey(_:)` and `deleteAPIKey()` that write to a config file the auth store already reads. Because the config file takes precedence over the env var, a saved key is an override. +- The provider delegates those to its auth store and reports its storage path and env name for the card's copy. +- `AppContainer` collects every `APIKeyManaging` provider into `apiKeyProviders`, which the card lists. + +Persist the key to a file the auth store already checks, so the file stays the source of truth and a user can edit it by hand. diff --git a/docs/architecture.md b/docs/architecture.md index da32385fe..7d8021ee5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,140 +1,68 @@ # Architecture -A high-level map of Runway's structure, for people who work on the code. For what the app -*does*, start with the [behavior docs](README.md). +A map of Runway's structure for people who work on the code. For what the app does, start with the [behavior docs](README.md). -## The shape of the app +## Layout -Runway is a SwiftPM package with a shared module and two thin executables — there is no Xcode project. -The main executable is a menu-bar app: a SwiftUI interface hosted inside an AppKit status item and panel. -The code is grouped by role: +Runway is a SwiftPM package with one shared module and two thin executables. There is no Xcode project. The main executable is a menu-bar app: a SwiftUI interface hosted inside an AppKit status item and panel. The code is grouped by role: -- `App/` — startup and the AppKit bridge (status item, panel, the app entry point). -- `Models/` — the small value types the rest of the app speaks in (`MetricLine`, `WidgetData`, descriptors). -- `Providers/` — one folder per provider (Claude, Codex, Cursor, Devin, Grok, Muse, OpenCode, …). -- `Stores/` — the mutable state the UI observes. -- `Services/` — shared infrastructure (HTTP, the local API, process running). -- `Support/` — small shared helpers (formatting, parsing, animations). -- `Views/` — the SwiftUI screens (dashboard, customize, settings, menu-bar strip). +- `App/`: startup and the AppKit bridge (status item, panel, app entry point) +- `Models/`: the value types the rest of the app uses (`MetricLine`, `WidgetData`, descriptors) +- `Providers/`: one folder per provider +- `Stores/`: the mutable state the UI observes +- `Services/`: shared infrastructure (HTTP, the local API, process running) +- `Support/`: small shared helpers (formatting, parsing, animations) +- `Views/`: the SwiftUI screens (dashboard, customize, settings, menu-bar strip) ## Composition root -`AppContainer` is the one place that wires everything together. At launch it builds the list of -providers, turns it into a `WidgetRegistry`, creates the stores, starts the periodic refresh loop, and -starts the local HTTP API. Everything else receives what it needs from here rather than reaching for -globals, which keeps the pieces testable in isolation. +`AppContainer` wires everything together. At launch it builds the provider list, turns it into a `WidgetRegistry`, creates the stores, starts the refresh loop, and starts the local HTTP API. Everything else receives what it needs from here instead of reaching for globals, which keeps the pieces testable. -The `runway` executable imports the same module. A normal invocation reads `ProviderSnapshotCache` -and exits; `--force` constructs the canonical `ProviderCatalog` and calls `WidgetDataStore`'s forced -refresh path before reading. Providers annotate the scalar resources they export through the stable -limits contract; the CLI and `/v1/limits` share one serializer over those same normalized snapshots. -It never launches the GUI or duplicates provider, auth, pricing, or mapping logic. +The `runway` executable imports the same module. A normal invocation reads `ProviderSnapshotCache` and exits. `--force` builds the `ProviderCatalog` and runs the forced refresh path in `WidgetDataStore` before reading. Providers mark the scalar resources they export through the limits contract. The CLI and `/v1/limits` share one serializer over the same normalized snapshots. The CLI never launches the GUI and never duplicates provider, auth, pricing, or mapping logic. ## The provider pipeline Each provider is a small module that conforms to `ProviderRuntime`. A refresh flows through three parts: -1. **Auth store** — reads credentials that already exist on the machine (config files, keychain). Runway - never asks the user to paste tokens. -2. **Usage client** — makes the HTTP calls to the provider's API. -3. **Mapper** — turns the provider's response into the app's own vocabulary: a `ProviderSnapshot` - containing typed widget values (`.progress`, `.values`, `.badge`, `.chart`) plus `.text` notices that - remain available through the local API but do not render as widgets. +1. **Auth store**: reads credentials that already exist on the machine (config files, keychain). Runway never asks the user to paste tokens. +2. **Usage client**: calls the provider's API. +3. **Mapper**: turns the response into a `ProviderSnapshot` with typed widget values (`.progress`, `.values`, `.badge`, `.chart`) plus `.text` notices, which reach the local API but do not render as widgets. -Because every provider produces the same normalized `MetricLine` shapes, the UI renders them all the same -way and doesn't need to know provider-specific details. To add one, see -[Adding a provider](adding-a-provider.md). +Every provider produces the same `MetricLine` shapes, so the UI renders them all the same way. See [Adding a provider](adding-a-provider.md). ### Credential ownership -The app that owns a credential is the only app allowed to change it. Provider credentials belong to -the provider's own tool (Claude Code, the `codex` CLI, the Cursor app, GitHub CLI, …). Runway never -writes any provider's **Keychain** item — the type system enforces that, because every credential -store holds the read-only `KeychainReading`. Runway's own private iCloud-sync device ID lives in -Application Support through `RunwayOwnedFileStore`, so it needs no Keychain write API. Grok and -Kimi remain the exception on the file side: Runway still refreshes those logins and saves them back -to their CLIs' own credential files. - -For Claude, Codex, Cursor, Copilot, and Muse, Runway never calls their OAuth token endpoints either, -because two apps rotating the same login can trip the server's token-reuse protection and sign the -user out. When one of those tokens lapses, the card shows a renewal notice naming the owning app; -Runway never renews it. Antigravity is the one endpoint-side exception: Runway refreshes its access -token through Google OAuth — safe because Google refresh tokens do not rotate — and caches the -result in Runway's own file, never writing back to Antigravity's Keychain item. Grok and Kimi still -refresh their own file-based logins (no Keychain involved); moving them to the same read-only model -is the remaining ownership follow-up. - -Automatic refreshes never request secret data from another app's Keychain item. They inspect only -non-secret metadata and reuse, for the rest of that process while the item is unchanged, a value -loaded by a manual refresh; -after launch or a credential change, the user connects the login again through a deliberate refresh. -That waiting state is deliberately neutral in the UI (a Connect affordance, not a warning): the -metadata-only pass defers the read, it is not denied it — only an actual denial of an attempted -manual read, an expired token, or an unreadable keychain warrants a warning. Manual **Refresh All** -queues protected providers and prompts for them one at a time, so approval dialogs never overlap. -If a refresh is cancelled while its read is still queued, that read leaves the queue without touching -Keychain. Clicking **Use** on a Codex reset credit may also prompt after every cached credential was -rejected. Both paths are user-initiated with the app in front of the user. - -Claude, Codex, and pi share `IncrementalJSONLScanner` for local JSONL history. The scanner caches -per-file parsed events by path, size, and modification time in a versioned Application Support store, -partitioned by provider/home identity. Provider instances that read the same home share one scanner -actor, which avoids duplicate parsing across cards; the disk store provides the reuse across process -launches. A session log that only grew since its last parse re-reads just the appended bytes and resumes -from the recorded parser state. Scans drop source-file records as their modification dates leave the -requested history window. The scanner also memoizes aggregation and pricing: when a refresh finds no log -changes (and the pricing snapshot, history window, and calendar configuration are unchanged), it reuses -the previous aggregation instead of pricing every cached event again. +The app that owns a credential is the only app that changes it. Provider credentials belong to the provider's own tool (Claude Code, the `codex` CLI, the Cursor app, GitHub CLI, and so on). Every credential store reads through the read-only `KeychainReading`. The one Keychain write path is `ClaudeCredentialWriteBack`, used only by the guarded Claude Code renewal described below. Runway's own iCloud-sync device ID lives in Application Support through `RunwayOwnedFileStore`. -## Stores +Runway never calls the OAuth token endpoints for Codex, Cursor, Copilot, or Muse. Two apps rotating the same login can trip the server's token-reuse protection and sign the user out. When one of those tokens lapses, the card shows a renewal notice naming the owning app. Claude Code is the guarded exception: when a stored token has already been expired for a while, so no live Claude Code session can be mid-rotation, Runway renews it and writes the result back to the same store (Keychain item or `.credentials.json`) so there is one token chain. See [Claude](providers/claude.md). Claude Desktop's login stays read-only. Antigravity refreshes its access token through Google OAuth, which is safe because Google refresh tokens do not rotate, and caches the result in Runway's own file. Grok and Kimi refresh their own file-based logins and write them back to the CLI's credential file. + +Automatic refreshes never request secret data from another app's Keychain item. They inspect only non-secret metadata and reuse, for the rest of the process while the item is unchanged, a value loaded by a manual refresh. After launch or a credential change, the user connects the login again through a manual refresh. That waiting state shows a neutral Connect control, not a warning. Only a denied manual read, an expired token, or an unreadable keychain shows a warning. Manual Refresh All queues protected providers and prompts for them one at a time, so approval dialogs never overlap. If a refresh is cancelled while its read is still queued, the read leaves the queue without touching Keychain. Clicking Use on a Codex reset credit may also prompt after every cached credential was rejected. Both paths are user-initiated. -The UI reads from a few observable stores: +Claude, Codex, and pi share `IncrementalJSONLScanner` for local JSONL history. The scanner caches parsed events per file by path, size, and modification time in a versioned Application Support store, partitioned by provider and home. Provider instances that read the same home share one scanner actor, so cards do not parse the same files twice. A session log that only grew since its last parse re-reads just the appended bytes. Records leave the cache as their file modification dates fall out of the history window. The scanner also memoizes aggregation and pricing: when a refresh finds no log changes and the pricing snapshot, history window, and calendar are unchanged, it reuses the previous aggregation. + +## Stores -- `WidgetDataStore` — the latest snapshot per provider, plus refresh and caching. It keeps machine-local - cached snapshots separate from rendered snapshots so peer history can never be written back out and - counted again. -- `LayoutStore` — which metrics are shown, the provider/metric order, and which metrics are starred for the - menu bar. -- `ProviderEnablementStore` — which providers the user has turned on or off. -- `ICloudUsageSyncStore` — one CloudKit record per device in the app's private database (history plus a - live snapshot for companion apps), a five-minute peer poll, and the visible device/error state. Cloud - access is injected for lifecycle and failure tests. +- `WidgetDataStore`: the latest snapshot per provider, plus refresh and caching. Machine-local cached snapshots are kept separate from rendered snapshots so peer history is never written back out and counted twice. +- `LayoutStore`: which metrics are shown, the provider and metric order, and which metrics are starred for the menu bar. +- `ProviderEnablementStore`: which providers are on or off. +- `ICloudUsageSyncStore`: one CloudKit record per device in the app's private database (history plus a live snapshot for companion apps), a five-minute peer poll, and the visible device and error state. Cloud access is injected for tests. -Refresh runs on a timer in `AppContainer`; each pass respects the cache, so the app only hits the -network once a snapshot has actually expired. +Refresh runs on a timer in `AppContainer`. Each pass respects the cache, so the app only hits the network once a snapshot has expired. -Providers with spend tiles carry an explicit history scope beside their export descriptors. Machine-local -sources can be summed across device records; account-wide sources such as Cursor cannot. `WidgetDataStore` -re-renders only the spend rows from the union, leaving quota and error state local. +Providers with spend tiles carry a history scope beside their export descriptors. Machine-local sources can be summed across device records. Account-wide sources such as Cursor cannot. `WidgetDataStore` re-renders only the spend rows from the union and leaves quota and error state local. ## The AppKit bridge -macOS menu-bar apps live in an `NSStatusItem`. Runway shows its content in a custom, key-capable -`NSPanel` rather than an `NSPopover`. A popover's window is only key while the whole app is active, -and activation of a menu-bar (accessory) app is asynchronous and unreliable on recent macOS — so a -popover cannot receive keystrokes until a second click. A non-activating `NSPanel` whose -`canBecomeKey` is `true` takes key focus the instant it opens, so keyboard navigation just works. -`App/` owns that AppKit layer and hosts the SwiftUI views inside it, so the bulk of the UI can stay -plain SwiftUI. +Runway shows its content in a custom key-capable `NSPanel` instead of an `NSPopover`. A popover's window is only key while the whole app is active, and activating a menu-bar app is asynchronous and unreliable on recent macOS, so a popover cannot receive keystrokes until a second click. A non-activating `NSPanel` with `canBecomeKey` set to true takes key focus the instant it opens. `App/` owns that AppKit layer and hosts the SwiftUI views inside it. -Settings is a separate, ordinary window (`App/SettingsWindowController.swift`) rather than a popover -screen: a preferences-style toolbar window. The controller creates it lazily on first open, mounts -only the active tab's SwiftUI pane, and tears it down entirely on close, so it costs nothing while -hidden. +Settings is an ordinary preferences-style window (`App/SettingsWindowController.swift`), not a popover screen. The controller creates it on first open, mounts only the active tab's SwiftUI pane, and tears it down on close. ## Platform support -Runway runs on macOS 15 (Sequoia) and later. It is built against the latest SDK and back-deploys: -on macOS 26 (Tahoe) it uses the system's Liquid Glass controls, and on macOS 15 it falls back to the -standard controls with the same behavior (the footer still pins, the buttons keep their states). Every -one of those version checks lives in a single file — `Support/LiquidGlassFallbacks.swift` — so the views -stay free of `#available` checks. +Runway runs on macOS 15 (Sequoia) and later. It is built against the latest SDK and back-deploys: on macOS 26 (Tahoe) it uses the system's Liquid Glass controls, and on macOS 15 it uses the standard controls with the same behavior. All of those version checks live in `Support/LiquidGlassFallbacks.swift`, so the views have no `#available` checks. -The release build (`script/release.sh`) ships a universal binary (arm64 + x86_64), so a single DMG runs -natively on both Apple Silicon and Intel Macs. The dev build (`script/build_and_run.sh`) stays host-arch -only — a universal dev build just doubles compile time on the maintainer's own machine for no benefit. +The release build (`script/release.sh`) is a universal binary (arm64 and x86_64). The dev build (`script/build_and_run.sh`) is host-arch only to keep compile time down. ## Local HTTP API -A small loopback server exposes the current usage as JSON on `127.0.0.1:6736` for other local tools. See -[Local HTTP API](local-http-api.md) for the endpoints and the privacy tradeoff. +A small loopback server exposes the current usage as JSON on `127.0.0.1:6736`. See [Local HTTP API](local-http-api.md). diff --git a/docs/cli.md b/docs/cli.md index 375ede40a..e1901f466 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,9 +1,6 @@ # Command-Line Interface -Runway ships a one-shot `runway` command for agents and scripts. It prints the documented -[`/v1/limits`](local-http-api.md#get-v1limits) JSON and exits; it never launches or leaves the menu-bar -app running. The output contains stable scalar limits and balances, not UI rows, colors, subtitles, -charts, or spend-history tiles. +Runway ships a one-shot `runway` command for agents and scripts. It prints the [`/v1/limits`](local-http-api.md#get-v1limits) JSON and exits. It never launches or leaves the menu-bar app running. The output contains scalar limits and balances, not UI rows, colors, subtitles, charts, or spend-history tiles. ```sh runway # every enabled provider, refreshing stale cache entries @@ -11,30 +8,14 @@ runway codex # one provider, refreshing when its cache is stale runway codex --force # refresh through the shared provider engine, cache, print, exit ``` -The command and app import the same providers, authentication stores, pricing, refresh coordinator, and -snapshot cache. A normal read reuses snapshots less than five minutes old and refreshes missing or stale -ones. `--force` bypasses that freshness gate and -writes successful results to the same cache. It is not a full substitute for the app's manual -refresh: nobody is watching a terminal command, so `--force` never opens a macOS Keychain approval -dialog. It also cannot inherit one — `runway` is a separate executable with its own signature, and -macOS grants Keychain access per binary, so approving an item inside the Runway app does not -authorize the command. A provider whose credential lives only in a protected Keychain item -therefore keeps working here through the shared snapshot the app writes (within its five-minute -freshness window), while a forced or stale read of that provider reports it as unavailable. The command uses credentials only on your machine; they -never appear in the output. - -A provider argument names providers by plain string matching, exactly like the -[local HTTP API](local-http-api.md). An exact provider ID names that provider. A family ID -(`claude`, `codex`) names every account card of that family. With one account, the family ID names -exactly that one card, so existing usage keeps working unchanged as multi-account support arrives. -The output envelope contains every matched provider; an ID that names nothing exits with an error. -There is no aliasing or account-picking logic. +The command and the app share the same providers, auth stores, pricing, refresh coordinator, and snapshot cache. A normal read reuses snapshots less than five minutes old and refreshes missing or stale ones. `--force` skips that freshness check and writes successful results to the same cache. + +`--force` is not a full substitute for the app's manual refresh. Nobody is watching a terminal command, so it never opens a macOS Keychain approval dialog. It also cannot inherit one: `runway` is a separate executable with its own signature, and macOS grants Keychain access per binary. A provider whose credential lives only in a protected Keychain item still works through the snapshot the app writes, within its five-minute freshness window. A forced or stale read of that provider reports it as unavailable. Credentials never appear in the output. + +A provider argument matches by plain string comparison, the same as the [local HTTP API](local-http-api.md). An exact provider ID names that provider. A family ID (`claude`, `codex`) names every account card of that family. With one account, the family ID names that one card. The output contains every matched provider. An ID that names nothing exits with an error. ## Install on `PATH` -In Runway, open **Settings → Advanced → Command Line** and click **Install…**. After the standard macOS -administrator prompt, `runway` is available globally in new terminal sessions. The installed symlink -points to the signed helper inside Runway, so in-place app updates also update the command. +In Runway, open **Settings → Advanced → Command Line** and click **Install…**. After the macOS administrator prompt, `runway` is available in new terminal sessions. The installed symlink points to the signed helper inside Runway, so app updates also update the command. -Exit codes are `0` for success, `2` for invalid arguments or an unknown provider, and `4` when a -refresh or local read fails. +Exit codes: `0` success, `2` invalid arguments or unknown provider, `4` refresh or local read failed. diff --git a/docs/dashboard.md b/docs/dashboard.md index 99eef788f..8e496b195 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -1,101 +1,102 @@ # Dashboard -The popover that opens from the menu bar icon. Providers are sections; each section shows the metrics you've enabled. +The popover that opens from the menu bar icon. Each provider is a card that shows the metrics you have enabled. ## First launch -A fresh install doesn't turn on every provider Runway knows about. It starts with Claude, Codex, and Cursor. It then checks which providers have credentials on your Mac (existing local logins, saved API keys, or supported environment variables — nothing is sent anywhere), and switches to exactly that set. If nothing is found, the Claude/Codex/Cursor starter set stays. A one-time card at the top of the dashboard explains this and points to **Customize**, where you can turn any provider on or off; the card stays until you close it with its ✕ button. +A fresh install starts with Claude, Codex, and Cursor. It then checks which providers have credentials on your Mac (local logins, saved API keys, or supported environment variables) and switches to exactly that set. Nothing is sent anywhere. If nothing is found, the Claude, Codex, and Cursor starter set stays. A one-time card at the top of the dashboard explains this and points to **Customize**, where you can turn any provider on or off. The card stays until you close it. -This full detection only happens on a brand-new install. Updates never change the providers you already have on or off — but when an update ships a provider you've never seen, the same local check runs once for just that provider and turns it on only if you actually have the tool. See [Which Providers Are On](provider-enablement.md) for the full lifecycle. +This full detection only runs on a new install. Updates never change the providers you already have on or off. When an update ships a provider you have never seen, the same local check runs once for that provider and turns it on only if you have the tool. See [Which Providers Are On](provider-enablement.md). -Each provider card leads with its **Always Visible** metrics. Any metrics you've moved below the **On Demand** line are tucked away behind the in-card caret — click it to reveal them below the caret, click again to collapse. Closing the popover collapses every open card, so the next open starts compact. A provider with neither On Demand metrics nor quick links shows no caret. +## Cards -When you expand a card, the tucked-away metrics open below the caret as a single-column list, so each detail row keeps the full card width. +Each provider card leads with its **Always Visible** metrics. Metrics you have moved to **On Demand** sit behind the card's caret. Click the caret to reveal them in a single-column list below it, and click again to collapse. Closing the popover collapses every open card. A provider with no On Demand metrics and no quick links shows no caret. -A provider card can also show **quick-link buttons** pinned at the bottom of its expanded section — Status, Console, Dashboard, and the like — that open the provider's own pages in your default browser. They're part of the expander, so collapsing the caret hides them along with the tucked-away metrics. Buttons lay out up to three across, wrapping to a second row when there are more. +A card can also show **quick-link buttons** at the bottom of its expanded section (Status, Console, Dashboard, and so on) that open the provider's own pages in your browser. They are part of the expander, so collapsing the caret hides them too. Buttons lay out up to three across and wrap to a second row. ## Total Spend -When any enabled provider tracks daily spend (Claude, Codex, Cursor, Grok, OpenCode, or Sakana Fugu), a card sits above the provider sections. The title is a pull-down menu for **Cost**, **Cost/MTok**, or **Tokens** (Cost is the default; the choice sticks across restarts). A capsule switcher flips the period between **Today**, **Yesterday**, and **30 Days**. The ring, center total, and ranked legend follow the selected metric: +When any enabled provider tracks daily spend (Claude, Codex, Cursor, Grok, OpenCode, or Sakana Fugu), a Total Spend card sits above the provider cards. The title is a pull-down menu for **Cost**, **Cost/MTok**, or **Tokens**. Cost is the default, and the choice persists across restarts. A capsule switcher flips the period between **Today**, **Yesterday**, and **30 Days**. The ring, center total, and ranked legend follow the selected metric: -- **Cost** — each segment is that provider's share of combined dollars (biggest spender first). -- **Cost/MTok** — each segment is sized by that provider's dollars-per-million-tokens rate; the center is the blended rate across providers that have both spend and tokens; the legend lists each provider's own rate. -- **Tokens** — each segment is that provider's share of combined tokens. +- **Cost**: each segment is that provider's share of combined dollars, biggest first. +- **Cost/MTok**: each segment is sized by that provider's dollars per million tokens. The center is the blended rate across providers that have both spend and tokens. The legend lists each provider's own rate. +- **Tokens**: each segment is that provider's share of combined tokens. -The ring center is always two short lines — a compact number on top and a quiet unit underneath (`$533` / `dollars`, `12.4` / `million`, or `$1.37` / `MTok`) — so Cost/MTok and big totals stay readable in the hole. Cost modes keep the `$` on the number. Hover the center for the exact one-line figure (and a note when any contributor's dollars are a local estimate — Cost and Cost/MTok only). In the legend, hover a provider row to reveal its full provider or account name. Its amount stays visible when the name already fits and fades from the left only as the name needs that space. Each provider keeps a fixed color drawn from its brand (Claude's terracotta, OpenAI's green, and so on), and even a tiny share keeps a visible sliver of the ring. Providers with nothing for the selected metric simply don't appear — they're never counted as zero. (An enabled provider counts even if you've hidden its own spend rows in Customize; other dollar rows, like OpenRouter's API spend, never mix in.) The header's share icon (or right-clicking the card) copies a branded PNG of the ring to your clipboard, just like sharing a provider card. The header also carries a small ⓘ naming the providers that feed the total. A period with nothing to show for the active metric shows a quiet empty state instead of hiding the card. Don't want the card at all? Turn it off with **Show Total Spend** at the top of [Settings](settings.md). +The ring center shows two short lines: a compact number and a unit (`$533` / `dollars`, `12.4` / `million`, or `$1.37` / `MTok`). Hover the center for the exact figure, and a note when any contributor's dollars are a local estimate (Cost and Cost/MTok only). In the legend, hover a provider row to see its full provider or account name. Each provider keeps a fixed brand color, and even a tiny share keeps a visible sliver of the ring. Providers with nothing for the selected metric do not appear. An enabled provider counts even if you have hidden its spend rows in Customize. Other dollar rows, like OpenRouter's API spend, never mix in. + +The header's share icon (or right-clicking the card) copies a branded PNG of the ring to your clipboard, like sharing a provider card. The header also carries an ⓘ naming the providers that feed the total. A period with nothing to show for the active metric shows an empty state instead of hiding the card. Turn the card off with **Show Total Spend** at the top of [Settings](settings.md). ## Rows -**Metrics with a limit** (session, weekly, credits with a cap) show a progress bar with: +**Metrics with a limit** (session, weekly, credits with a cap) show a progress bar: -- A fill whose color is a verdict on the whole window, based on your current burn rate. Blue: you're on course to finish with at least 10% to spare. Yellow: you're projected to land inside the last 10% with a little cushion to spare. Red: you're projected to run out before the reset — or to finish right at the limit with nothing to spare. So a half-full bar burning too fast is already red, and a nearly-drained bar coasting to the reset stays blue. Bars without a reset window (like a credit balance), and fresh windows too young to project, color by the level itself instead: yellow once 80% is used, red once 10% or less is left. The colors come from the system palette, so they adapt to light/dark and accessibility settings, and they never flip with the Used/Left toggle. -- A headline like `52% left` or `48% used`. **Click it** to flip between Used and Left everywhere — hovering shows the opposite reading. -- A reset label like `Resets in 3h 25m` or `Resets today at 6:38 PM`. **Click it** to flip between countdown and exact time everywhere — hovering shows the other format. -- A blue bar carries nothing extra by default. With **Always Show Pacing** on (Settings), it also shows an even-pace tick on the bar and a quiet `~35% left at reset` note next to the metric name. A metric with nothing used yet stays plain even then — there's no pace to project until something has been spent. -- A yellow bar adds a `~3% spare` note right-aligned next to the metric name, plus the even-pace tick on the bar (where usage sits if you burn evenly across the window). That cushion is always at least 1%; if you're projected to finish with nothing to spare it turns red instead (so a yellow bar never reads `~0% spare`). -- A red bar swaps the note for a red flame next to the metric name with the projected run-out time — `Limit in 3h 5m` or `Limit today at 11:49 PM`, following the same countdown/exact format as the reset label — and still shows the even-pace tick on the bar. **Click the time** to flip the format everywhere, just like clicking the reset label. When you're projected to finish right at the limit — no run-out before the reset, just no cushion left — the flame shows alone with no time. -- Once the balance is spent — actually empty, or so close it rounds to `0` (like `0% left` or `$0.00`) — the bar stays red and the flame reads `Limit reached`, no matter how gentle the burn rate looked. A visibly empty bar never shows a calmer color. -- **Hover the bar**, the spare note, or the flame for the pace projection at reset — the one number not already on the row: a blue bar shows the cushion you're on course to finish with (`~35% left at reset`), a yellow bar the usage it complements the spare note with (`~92% used at reset`), a red bar how far past the limit you're projected to land (`~12% over limit at reset`, or `~100% used at reset` when you're projected to finish right at it). Once spent it reads `Limit reached`. +- The fill color is a verdict on the whole window based on your current burn rate. Blue: you are on course to finish with at least 10% to spare. Yellow: you are projected to land inside the last 10% with a little left. Red: you are projected to run out before the reset, or to finish right at the limit. A half-full bar burning too fast is red, and a nearly empty bar coasting to the reset stays blue. Bars without a reset window (like a credit balance) and windows too young to project color by level instead: yellow at 80% used, red at 10% or less left. Colors come from the system palette, so they adapt to light and dark mode and accessibility settings. They never change with the Used/Left toggle. +- A headline like `52% left` or `48% used`. Click it to flip between Used and Left everywhere. Hovering shows the other reading. +- A reset label like `Resets in 3h 25m` or `Resets today at 6:38 PM`. Click it to flip between countdown and exact time everywhere. Hovering shows the other format. +- A blue bar carries nothing extra by default. With **Always Show Pacing** on in Settings, it also shows an even-pace tick on the bar and a `~35% left at reset` note next to the metric name. A metric with nothing used yet stays plain. +- A yellow bar adds a `~3% spare` note next to the metric name and an even-pace tick on the bar (where usage would sit if you burned evenly across the window). The cushion is always at least 1%. If you are projected to finish with nothing to spare, the bar turns red instead. +- A red bar shows a red flame next to the metric name with the projected run-out time (`Limit in 3h 5m` or `Limit today at 11:49 PM`, in the same format as the reset label) and the even-pace tick. Click the time to flip the format everywhere. When you are projected to finish right at the limit with no run-out before the reset, the flame shows alone. +- Once the balance is spent, or so close that it rounds to `0`, the bar stays red and the flame reads `Limit reached` regardless of burn rate. +- Hover the bar, the spare note, or the flame for the projection at reset: a blue bar shows the cushion (`~35% left at reset`), a yellow bar the projected usage (`~92% used at reset`), a red bar how far over you land (`~12% over limit at reset`, or `~100% used at reset` when you finish right at it). Once spent it reads `Limit reached`. -**Metrics without a limit** (daily spend, balances) show as a single line like `$4.08 spent` or `1.2M tokens`. The Today / Yesterday / Last 30 Days rows combine cost and tokens (`$4.08 · 1.2M tokens`) and can be turned on or off in Customize. A day with no usage reads "No data" rather than a misleading `$0.00 · 0 tokens` — the same as when the source can't be loaded at all. Big numbers are abbreviated to keep rows tidy (`$2.06K`, `1.5B`) — hover the value to see the exact figures and source note, such as a local estimate. +**Metrics without a limit** (daily spend, balances) show as a single line like `$4.08 spent` or `1.2M tokens`. The Today, Yesterday, and Last 30 Days rows combine cost and tokens (`$4.08 · 1.2M tokens`) and can be turned on or off in Customize. A day with no usage reads "No data" instead of `$0.00 · 0 tokens`, the same as when the source cannot be loaded. Big numbers are abbreviated (`$2.06K`, `1.5B`). Hover the value for the exact figures and the source note, such as a local estimate. -For Claude, Codex, Cursor, Grok, OpenCode, and Sakana Fugu spend rows, the value gently highlights when you point at it, signaling it's interactive; hovering it for a moment opens a small model breakdown for that period: a ranked list of models, each showing its name and spend on one line, its share percentage and tokens on the next, and a thin share bar. Cursor groups its per-thinking-effort export slugs (like `claude-opus-4-8-thinking-max`) under the base model. Long tails fold into **Other** — anything past the top named models or under 5% of the period. Models no pricing source can price don't appear here (or in the row's totals) at all; the row's warning triangle names them instead (see [Pricing](pricing.md)). +For Claude, Codex, Cursor, Grok, OpenCode, and Sakana Fugu spend rows, hovering the value for a moment opens a model breakdown for that period: a ranked list of models with name and spend on one line, share percentage and tokens on the next, and a thin share bar. Cursor groups its per-thinking-effort export slugs (like `claude-opus-4-8-thinking-max`) under the base model. Long tails fold into **Other** (anything past the top named models or under 5% of the period). Models no pricing source can price do not appear here or in the row's totals. The row's warning triangle names them instead (see [Pricing](pricing.md)). -**Usage Trend** (Claude, Codex, Cursor, Grok, OpenCode, and Sakana Fugu) is a small bar chart of the last 30 days of token usage — one bar per day, drawn from the same source as that provider's spend rows (local logs for Claude, Codex, Grok, OpenCode, and Sakana Fugu; Cursor's usage export for Cursor). **Hover it** for the peak day, the date range, and the source. It's on by default; turn it off or reorder it from Customize like any other metric. It can't be starred for the menu bar — the strip shows single values, not a chart. +**Usage Trend** (Claude, Codex, Cursor, Grok, OpenCode, and Sakana Fugu) is a small bar chart of the last 30 days of token usage, one bar per day, from the same source as that provider's spend rows. Hover it for the peak day, the date range, and the source. It is on by default. Turn it off or reorder it from Customize like any other metric. It cannot be starred for the menu bar. -**When a provider can't load at all** — its refresh failed and there's no earlier data to keep showing (say, a provider that isn't signed in) — the card replaces its metric rows with the reason and a **Refresh** button. A Keychain login that still needs your one-time approval gets the same card in a neutral dress: a muted key glyph and a **Connect** button instead of the warning styling, because nothing is broken — the login just needs one approval, granted through that click (already-approved logins load silently in the background, and in the header, this state shows a muted key glyph where a failure would show the amber triangle). The provider's quick links (Status, Dashboard, …) stay available behind the card's caret, since those pages are often what resolves the problem. That button is the deliberate action that can show a macOS permission prompt; background refreshes never do. Once a provider has loaded at least once, a later failure keeps the last-good rows on screen and reports the problem with the amber triangle at the right edge of the provider's header instead — clicking that triangle refreshes the provider, the same deliberate action as the button (see [Refreshing](refreshing.md)). +**When a provider cannot load at all** (its refresh failed and there is no earlier data to show), the card replaces its metric rows with the reason and a **Refresh** button. A Keychain login that still needs your one-time approval gets the same card with neutral styling: a muted key glyph and a **Connect** button instead of a warning, because nothing is broken. Already-approved logins load silently in the background. The provider's quick links stay available behind the caret. The button is the one action that can show a macOS permission prompt. Background refreshes never do. Once a provider has loaded at least once, a later failure keeps the last-good rows on screen and shows an amber triangle at the right edge of the header. Clicking the triangle refreshes the provider (see [Refreshing](refreshing.md)). -**Long card names** (like an account card renamed to `Claude — matt@example.com`) get the full header line. If a name still doesn't fit, hovering the header scrolls it once to its ending and holds there — the same reveal the Total Spend legend uses for long account names. +**Long card names** (like `Claude — matt@example.com`) get the full header line. If a name still does not fit, hovering the header scrolls it once to its end and holds there. -With [iCloud Sync](icloud-sync.md) on, the machine-local providers' spend rows, trends, warnings, and -model breakdowns are rebuilt from all synced Macs. Cursor stays unchanged because its export is already -account-wide. Quotas, plans, balances, and provider errors always describe this Mac's refresh. +With [iCloud Sync](icloud-sync.md) on, the machine-local providers' spend rows, trends, warnings, and model breakdowns are rebuilt from all synced Macs. Cursor is unchanged because its export is already account-wide. Quotas, plans, balances, and provider errors always describe this Mac's refresh. Rows with a reset date tick every 30 seconds, so countdowns and pace stay live between refreshes. -Runway honors the system Reduce Motion setting (System Settings → Accessibility → Display): screen switches and panel growth swap their springs and slides for quick fades. +Runway honors the system Reduce Motion setting. Screen switches and panel growth use quick fades instead of springs and slides. ## Right-click menus -Every row: **Hide · Star for menu bar / Unstar · Refresh \ · Customize…** (Customize opens straight to that provider's metrics.) -Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). Claude and Codex cards also offer **Rename…** — give the card any name you like (handy with multiple accounts); leave the field empty to go back to the default name. The name follows the card everywhere it's shown: the dashboard, the Total Spend legend, share screenshots, notifications, and the CLI/API output. +Every row: **Hide**, **Star for menu bar** / **Unstar**, **Refresh \**, **Customize…** (opens straight to that provider's metrics). + +Provider headers: **Hide \** (turns the whole provider off; turn it back on in Customize), **Refresh \**, **Customize…**, and **Share Screenshot** (see below). Claude and Codex cards also offer **Rename…**. Give the card any name you like, which helps with multiple accounts. Leave the field empty to go back to the default name. The name follows the card everywhere: the dashboard, the Total Spend legend, share screenshots, notifications, and the CLI and API output. ## Share -Copy a clean, branded PNG of one provider's usage to your clipboard, ready to paste into a chat, a tweet, or a doc. There are two ways to reach it: +Copy a branded PNG of one provider's usage to your clipboard: - Right-click a provider header and choose **Share Screenshot**. -- Open the footer's **gear** menu and choose **Share Screenshot** ▸ *\*. The submenu lists every provider currently showing on the dashboard. +- Open the footer's **gear** menu and choose **Share Screenshot** ▸ *\*. The submenu lists every provider on the dashboard. -The image is a flexible-height PNG using the app's look — the provider's mark and name up top, the metric rows you currently see for that provider, and a small Runway mark centered at the bottom. It follows your Light/Dark appearance and shows everything on the card as-is (nothing is hidden or blurred). +The image shows the provider's mark and name, the metric rows you currently see for that provider, and a small Runway mark at the bottom. It follows your Light/Dark appearance and shows everything on the card as-is. Nothing is hidden or blurred. ## Footer -The compact bar pinned to the bottom of the popover — it stays glued to the panel's bottom edge as the popover grows and shrinks. On the left: the app version. On the right: a live countdown to the next update (like `5m`) you can click (or press **⌘R**) to refresh right away, and a **gear** menu button. The gear holds everything in one place — **Customize**, **Settings** (opens the [Settings window](settings.md)), **Memory** (opens the [Memory Explorer](memory-explorer.md)), **Share Screenshot** (submenu of providers), **Check for Updates…**, **About Runway**, and **Quit Runway**. +The bar pinned to the bottom of the popover. On the left: the app version. On the right: a countdown to the next update (like `5m`) that you can click, or press **⌘R**, to refresh now, and a **gear** menu. The gear holds **Customize**, **Settings** (opens the [Settings window](settings.md)), **Memory** (opens the [Memory Explorer](memory-explorer.md)), **Share Screenshot**, **Check for Updates…**, **About Runway**, and **Quit Runway**. ## Customize -Open Customize from the footer's **gear** menu (or press **Return**). It's a two-level screen: a list of providers, then a provider's detail. +Open Customize from the footer's **gear** menu or press **Return**. It has two levels: a list of providers, then a provider's detail. -The **provider list** shows every provider with a switch to turn it on or off, a count of its metrics, and a chevron into its detail. Turn a provider off and it stays in the list, greyed — its metrics hide from the dashboard and menu bar but keep their setup for when you turn it back on. Drag enabled providers by their grip to reorder; tap a row to open its detail. On a fresh install only the providers detected on your Mac start on (see "First launch" above); this list is where you add the rest. +The **provider list** shows every provider with an on/off switch, a count of its metrics, and a chevron into its detail. A provider turned off stays in the list, greyed. Its metrics leave the dashboard and menu bar but keep their setup for when you turn it back on. Drag enabled providers by their grip to reorder. Tap a row to open its detail. On a fresh install only the providers detected on your Mac start on (see "First launch" above). This list is where you add the rest. -A provider's **detail** has a back button and provider-specific Reset control in its top bar. Claude and Codex cards start with a **Name** field — the same rename the card's right-click menu offers; clear it to go back to the default name. Then come two metric sections: **Always Visible** (shown on the dashboard card) and **On Demand** (tucked behind the card's caret). Each metric row has a drag grip, its name, an always-visible star for the menu bar, and an on/off switch. Drag a metric into the other card—or onto one of that card's rows—to move it between Always Visible and On Demand. An empty card shows a dashed **Drag metrics here** target. You can star up to two metrics per provider. OpenRouter and Z.ai also show an **API Key** section here, where you can add, replace, reveal, or clear that provider's key. +A provider's **detail** has a back button and a Reset control in its top bar. Claude and Codex cards start with a **Name** field, the same rename the card's right-click menu offers. Then come two metric sections: **Always Visible** (shown on the card) and **On Demand** (behind the caret). Each metric row has a drag grip, its name, a star for the menu bar, and an on/off switch. Drag a metric into the other section, or onto one of its rows, to move it. An empty section shows a dashed **Drag metrics here** target. You can star up to two metrics per provider. OpenRouter and Z.ai also show an **API Key** section here, where you can add, replace, reveal, or clear that provider's key. -Drag-reorder also works directly on the dashboard — drag a row within its provider, drag it across the caret boundary while the card is open, or drag a provider header to reorder sections. On a Force Touch trackpad you'll feel a light tap each time the dragged item snaps into a new slot. +Drag-reorder also works on the dashboard: drag a row within its provider, drag it across the caret boundary while the card is open, or drag a provider header to reorder cards. On a Force Touch trackpad you feel a light tap each time the dragged item snaps into a new slot. -For Claude, the default reset layout keeps Session, Weekly, and Fable always visible. Codex and Grok keep only Weekly always visible, while Sakana Fugu and Muse keep Five-Hour Usage and Weekly Usage always visible. For Claude, Codex, Grok, and Sakana Fugu, Usage Trend and the Today, Yesterday, and Last 30 Days history rows start on demand; Codex and Grok also put Rate Limit Resets there, above the usage history. Their other metrics start off. Other providers keep their own core meters above the caret and secondary details on demand. +For Claude, the default layout keeps Session, Weekly, and Fable always visible. Codex and Grok keep only Weekly always visible. Sakana Fugu and Muse keep Five-Hour Usage and Weekly Usage always visible. For Claude, Codex, Grok, and Sakana Fugu, Usage Trend and the Today, Yesterday, and Last 30 Days rows start on demand. Codex and Grok also put Rate Limit Resets there, above the usage history. Their other metrics start off. Other providers keep their core meters above the caret and secondary details on demand. -Made a change you didn't mean to? Press **⌘Z** to undo — it works anywhere in the popover (the dashboard and Customize alike) and steps back through your recent customization changes one at a time: hiding or showing a metric, reordering metrics or whole providers, starring or unstarring, and moving a metric across the divider all undo. Each step restores the exact previous arrangement. Undo is per-session (it starts fresh after a relaunch), and resetting clears it. +Press **⌘Z** to undo. It works anywhere in the popover and steps back through your recent customization changes one at a time: hiding or showing a metric, reordering metrics or providers, starring or unstarring, and moving a metric across the divider. Undo is per session and resetting clears it. -When Runway ships a new default metric, existing layouts get it once, in that provider's default position. If you turn it off, it stays off. A provider's **Reset** button (top right of its detail) restores that provider's default metrics, order, menu-bar stars, and which metrics start on demand, but leaves other providers and the provider order untouched. The **Reset All Customization** button (top right of the provider list) does the same for every provider at once, restores the default provider order, and re-detects your installed tools. It turns providers back on for exactly the tools set up on your Mac, just like first launch (see [Which Providers Are On](provider-enablement.md)). It asks for confirmation first, since it wipes the whole layout and re-detects providers, and can't be undone. +When Runway ships a new default metric, existing layouts get it once, in that provider's default position. If you turn it off, it stays off. A provider's **Reset** button restores that provider's default metrics, order, menu-bar stars, and On Demand set, and leaves other providers and the provider order alone. **Reset All Customization** at the top of the provider list does the same for every provider, restores the default provider order, and re-detects your installed tools. It turns providers on for exactly the tools set up on your Mac, like first launch (see [Which Providers Are On](provider-enablement.md)). It asks for confirmation first and cannot be undone. ## Keyboard | Key | Action | |---|---| -| Return | From the dashboard, open Customize; from a provider detail, return to the provider list; from the provider list, return to the dashboard | -| Esc | From a provider detail, return to the provider list; from the provider list, return to the dashboard; from the dashboard, close the popover | -| ⌘Z | Undo the last customization change (app-wide; repeat to step back) | +| Return | From the dashboard, open Customize; from a provider detail, go back to the provider list; from the provider list, go back to the dashboard | +| Esc | From a provider detail, go back to the provider list; from the provider list, go back to the dashboard; from the dashboard, close the popover | +| ⌘Z | Undo the last customization change (repeat to step back) | | ⌘R | Refresh now from the dashboard (skips the cache) | | ⌘, | Open the [Settings window](settings.md) (closes the popover) | | ⌘M | Open the [Memory Explorer](memory-explorer.md) (closes the popover) | @@ -104,4 +105,4 @@ A global shortcut (recorded in Settings) toggles the popover from anywhere. ## Closing -Closing the popover resets navigation state: scroll position returns to the top, Customize closes, and every provider card collapses. +Closing the popover resets navigation: scroll returns to the top, Customize closes, and every provider card collapses. diff --git a/docs/debugging.md b/docs/debugging.md index 3c60e7423..0d1ffd67d 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -1,11 +1,10 @@ # Debugging and Capturing Logs -How to run a local build and watch what the app is doing — useful when a provider misbehaves or you're -chasing a startup or refresh problem. +How to run a local build and watch what the app is doing. ## Run a local build -The project script owns the build/run loop. From the repo root: +From the repo root: ```sh ./script/build_and_run.sh # build and launch the dev app from dist/ @@ -13,15 +12,9 @@ The project script owns the build/run loop. From the repo root: ./script/build_and_run.sh verify # launch and confirm the process is running ``` -The script builds a signed app bundle under `dist/` and launches it in place — nothing is installed to -`/Applications`. The dev build uses its own bundle id (`com.mattstallone.runway.dev`), so it keeps its -own settings and keychain and never disturbs a released Runway. It ships no update feed, so it never -checks for updates — test updates with a real signed, notarized release build. +The script builds a signed app bundle under `dist/` and launches it in place. Nothing is installed to `/Applications`. The dev build has its own bundle id (`com.mattstallone.runway.dev`), so it keeps its own settings and keychain and never disturbs a released Runway. It has no update feed, so it never checks for updates. Test updates with a real signed, notarized release build. -The script launches the app without any `CLAUDE_CONFIG_DIR` / `CODEX_HOME` values inherited from the -shell, and clears the dev app's persisted shell-environment snapshot first. This keeps an agent -session's sandboxed homes (Claude Code and Codex export those variables) from leaking into the dev -app's Claude and Codex data. To test a custom home on purpose, launch with `KEEP_PROVIDER_HOMES=1`. +The script launches the app without any `CLAUDE_CONFIG_DIR` or `CODEX_HOME` values inherited from the shell, and clears the dev app's persisted shell-environment snapshot first. This keeps an agent session's sandboxed homes (Claude Code and Codex export those variables) out of the dev app's Claude and Codex data. To test a custom home on purpose, launch with `KEEP_PROVIDER_HOMES=1`. ## Stream logs @@ -31,14 +24,13 @@ To watch the app's logs live while you reproduce an issue: ./script/build_and_run.sh logs ``` -This launches the dev app and then streams its unified logs. Under the hood it filters the system log to -the app's process, equivalent to: +This launches the dev app and streams its unified logs. It is equivalent to: ```sh log stream --info --style compact --predicate 'process == "Runway"' ``` -To read logs *after the fact* instead of live, use `log show` with a time window: +To read logs after the fact, use `log show` with a time window: ```sh log show --last 10m --info --predicate 'process == "Runway"' @@ -46,92 +38,42 @@ log show --last 10m --info --predicate 'process == "Runway"' ## Log file -In addition to the unified log above, the app writes a file log to -`~/Library/Logs/Runway/Runway.log` — this is what to send with a support report. It is capped at -~10 MB with one `.1` archive. Raise the detail in **Settings -> Advanced -> Log Level** (use **Debug** -for full detail), then grab the file with **Copy Log Path** or **Reveal in Finder** in that same -section. See [Logging](logging.md) for the levels, subsystem tags, and the never-log-secrets guarantee. +The app also writes a file log to `~/Library/Logs/Runway/Runway.log`. This is what to send with a support report. It is capped at about 10 MB with one `.1` archive. Raise the detail in **Settings → Advanced → Log Level** (use **Debug** for full detail), then grab the file with **Copy Log Path** or **Reveal in Finder**. See [Logging](logging.md). ## Account log lines -The launch-time account pass (which account is signed in at the Claude/Codex default home) leaves a -short trail in the log file: - -- `accounts: claude default identity resolved (claude@)` — the default login named its account. - The hash is derived from the account id, so two launches by the same account always match. -- `accounts: codex default identity unresolved — …` — a login exists but its account can't be named - with certainty this launch (for example, an auth file without an account id or an unbound - account-scoped keyring credential). The legacy card still works; that source can't participate in - account-aware features until its identity is verified. -- `discovery: codex candidate ~/.codex-work: accepted (, auth.json)` — a custom Codex home - supplied a usable, account-named OAuth login and participates in an account card. -- `discovery: codex candidate ~/.codex-work: keyring identity unverified → hidden until its exact - item is bound` — the home uses account-scoped keyring storage, but launch discovery has not safely - associated that item with its provider account yet. -- `discovery: bound Codex keyring identity for home ; card appears next launch` — a user-attended Refresh All read that one - exact item and recorded a fingerprint-bound account association; its card can appear next launch. -- `discovery: codex candidate ~/.codex-work: accepted (, verified keyring)` — the keyring item - still matches the verified binding and now participates in an account card. -- `discovery: codex candidate ~/.codex-work: OAuth credential names no account → skipped` — the - home has a token but no provider-owned account identity, so Runway refuses to guess from its - path and can't safely turn it into a separate card. -- `accounts: codex card codex@ from 2 home(s)` — the account card was assembled, and the - number says how many same-account Codex homes contribute local session logs to it. -- `stale account cache discarded for claude` — the account at the default home changed between - launches, so the previous account's cached snapshot was dropped instead of painting under the new - login. -- `account identity read skipped for claude, codex: login shell cold and no shell-environment - snapshot exists yet` — the bounded login-shell capture failed on a launch with no persisted - snapshot, so the named families were safely left unread rather than assembled from the wrong home. +The launch-time account pass (which account is signed in at the Claude and Codex default home) leaves a short trail in the log file: + +- `accounts: claude default identity resolved (claude@)`: the default login named its account. The hash is derived from the account id, so two launches by the same account match. +- `accounts: codex default identity unresolved — …`: a login exists but its account cannot be named with certainty this launch (for example, an auth file without an account id, or an unbound account-scoped keyring credential). The card still works, but that source cannot take part in account-aware features until its identity is verified. +- `discovery: codex candidate ~/.codex-work: accepted (, auth.json)`: a custom Codex home supplied a usable, account-named OAuth login and takes part in an account card. +- `discovery: codex candidate ~/.codex-work: keyring identity unverified → hidden until its exact item is bound`: the home uses account-scoped keyring storage, but launch discovery has not yet associated that item with its account. +- `discovery: bound Codex keyring identity for home ; card appears next launch`: a user-attended Refresh All read that exact item and recorded a fingerprint-bound account association. +- `discovery: codex candidate ~/.codex-work: accepted (, verified keyring)`: the keyring item still matches the verified binding and takes part in an account card. +- `discovery: codex candidate ~/.codex-work: OAuth credential names no account → skipped`: the home has a token but no account identity, so Runway does not guess from its path. +- `accounts: codex card codex@ from 2 home(s)`: the account card was assembled from that many same-account Codex homes. +- `stale account cache discarded for claude`: the account at the default home changed between launches, so the previous account's cached snapshot was dropped. +- `account identity read skipped for claude, codex: login shell cold and no shell-environment snapshot exists yet`: the login-shell capture failed on a launch with no persisted snapshot, so those families were left unread rather than assembled from the wrong home. ## Profile the UI -`script/profile_ui.sh` measures popover performance end to end. It builds and stages the dev app, -relaunches it with `RUNWAY_UI_PROFILE=1`, and an in-app driver walks the popover through scripted -phases — a cold open, twelve warm open/close cycles, ten screen switches, ten caret toggles, a -forced refresh with the panel open, and an idle soak. The script then prints per-phase stats from -the log: open latency broken into layout and order-front, close cost, and main-queue stalls. +`script/profile_ui.sh` measures popover performance end to end. It builds and stages the dev app, relaunches it with `RUNWAY_UI_PROFILE=1`, and an in-app driver walks the popover through scripted phases: a cold open, twelve warm open/close cycles, ten screen switches, ten caret toggles, a forced refresh with the panel open, and an idle soak. The script prints per-phase stats from the log: open latency split into layout and order-front, close cost, and main-queue stalls. -Run it before and after any change that touches the popover render path, and compare. Reference -numbers from this machine class (Apple Silicon, August 2026): warm open stays in the low tens of -milliseconds to first frame, and the warm-cycles phase reports few or no stalls. +Run it before and after any change to the popover render path and compare. Reference numbers on Apple Silicon (August 2026): warm open in the low tens of milliseconds to first frame, and few or no stalls in the warm-cycles phase. -The `RUNWAY_UI_PROFILE` gate is inert in normal use — no timing code runs without it. The stall -watchdog measures how long async main-actor work waits (main-queue latency), which is a proxy for -responsiveness, not a literal dropped-frame count. +`RUNWAY_UI_PROFILE` is inert in normal use. No timing code runs without it. The stall watchdog measures how long async main-actor work waits (main-queue latency), which is a proxy for responsiveness, not a dropped-frame count. -The script's "cold open" measures the shipped first-click experience, which includes the launch -pre-warm (it runs at +2s, before the driver's first open at +6s). To measure the true cold path — -no pre-warm at all — launch with `RUNWAY_UI_PROFILE_COLD=1` as well. +The "cold open" measures the shipped first-click experience, which includes the launch pre-warm (it runs at +2s, before the driver's first open at +6s). To measure the true cold path with no pre-warm, launch with `RUNWAY_UI_PROFILE_COLD=1` as well. ## Profile the Memory window -`script/profile_memory_ui.sh` is the same harness for the Memory Explorer. It relaunches the dev -app with `RUNWAY_UI_PROFILE_MEMORY=1`, and the driver walks the window through scripted phases — a -cold open with the initial scan, six close/open cycles (each rebuilds the store, by design), twelve -file-document selection switches, six database-row loads, three re-scans, and an idle soak. Run it -before and after any change that touches the Memory window's render or load paths. +`script/profile_memory_ui.sh` is the same harness for the Memory Explorer. It relaunches the dev app with `RUNWAY_UI_PROFILE_MEMORY=1`, and the driver walks the window through a cold open with the initial scan, six close/open cycles (each rebuilds the store), twelve file-document selection switches, six database-row loads, three re-scans, and an idle soak. Run it before and after any change to the Memory window's render or load paths. -Reference numbers from this machine class (Apple Silicon, August 2026): a warm open builds the -window in ~20ms and the scan lands ~20ms later; a re-scan with the window open is under 20ms (an -unchanged Codex database re-lists from cache in under 1ms); selecting a file document loads in -under 10ms and a database row in under 20ms. The selection, database, and idle phases report zero -main-queue stalls; each open pays one or two ~60ms stalls for the SwiftUI tree mount, which -overlaps the window materializing. +Reference numbers on Apple Silicon (August 2026): a warm open builds the window in about 20ms and the scan lands about 20ms later. A re-scan with the window open is under 20ms (an unchanged Codex database re-lists from cache in under 1ms). Selecting a file document loads in under 10ms and a database row in under 20ms. The selection, database, and idle phases report zero main-queue stalls. Each open pays one or two 60ms stalls for the SwiftUI tree mount, which overlaps the window appearing. ## Tips -- **A provider shows an error.** Reproduce with `logs` running, then check that provider's page in - `docs/providers/` for what its error states mean and where it reads credentials from. -- **Nothing updates.** Refresh runs on a timer and respects the cache; see - [Refreshing & caching](refreshing.md) for when a network call actually happens. Use the per-provider - "Refresh" in the row's context menu to force one. -- **Permissions / keychain prompts on every rebuild.** The script signs with a stable Apple Development - identity so the permission ACLs stick. An ad-hoc-signed build (a bare `swift build` binary, or the - script's fallback when no identity is installed) cannot hold a durable approval — its identity is the - binary's own hash, so every rebuild would prompt again. Runway therefore refuses to open keychain - approval dialogs from ad-hoc builds: keychain-backed providers stay on their neutral Connect state, - and the log says why (`interactive keychain read refused`). If Connect seems to do nothing in a dev - build, that's the cause — make sure an Apple Development identity exists in your keychain. -- **Inspect the local API.** With the app running, `curl 127.0.0.1:6736/v1/usage` shows the same usage - snapshots the UI uses — handy to confirm whether a problem is in fetching/mapping or in the UI. +- **A provider shows an error.** Reproduce with `logs` running, then check that provider's page in `docs/providers/` for what its error states mean and where it reads credentials. +- **Nothing updates.** Refresh runs on a timer and respects the cache. See [Refreshing & caching](refreshing.md). Use the per-provider "Refresh" in the row's context menu to force one. +- **Keychain prompts on every rebuild.** The script signs with a stable Apple Development identity so the permission ACLs stick. An ad-hoc-signed build (a bare `swift build` binary, or the script's fallback when no identity is installed) cannot hold a durable approval, because its identity is the binary's own hash, so every rebuild would prompt again. Runway therefore refuses to open keychain approval dialogs from ad-hoc builds: keychain-backed providers stay on the Connect state, and the log says why (`interactive keychain read refused`). If Connect seems to do nothing in a dev build, make sure an Apple Development identity exists in your keychain. +- **Inspect the local API.** With the app running, `curl 127.0.0.1:6736/v1/usage` shows the same snapshots the UI uses. This helps tell a fetching or mapping problem from a UI problem. diff --git a/docs/icloud-sync.md b/docs/icloud-sync.md index ce8122510..cc43a78fb 100644 --- a/docs/icloud-sync.md +++ b/docs/icloud-sync.md @@ -1,119 +1,56 @@ # iCloud Sync -**Sync Across Macs** is on by default; you can turn it off in Settings. While it is on, each -device keeps one versioned record in -Runway's private CloudKit database — part of your own iCloud account — and reads the records written -by your other devices. Runway keeps a random device ID in its private Application Support data, so the same Mac -continues to update its existing record after you reset app preferences or reinstall the app. There is no -folder picker, pairing code, or separate account. - -Upgrades from either older Keychain-backed device-ID format normally copy the saved ID into the -current private file without reading a Keychain secret. If both that saved copy and the current file are missing, Settings pauses publishing -and offers **Recover Identity**. That explicit action may show a macOS Keychain approval dialog; -automatic sync work never requests the legacy value. +**Sync Across Macs** is on by default. You can turn it off in Settings. While it is on, each device keeps one record in Runway's private CloudKit database, part of your own iCloud account, and reads the records written by your other devices. Runway keeps a random device ID in its private Application Support data, so the same Mac keeps updating its existing record after you reset preferences or reinstall. There is no folder picker, pairing code, or separate account. + +Upgrades from either older Keychain-backed device-ID format copy the saved ID into the current file without reading a Keychain secret. If both that saved copy and the current file are missing, Settings pauses publishing and offers **Recover Identity**. That action may show a macOS Keychain approval dialog. Automatic sync never requests the legacy value. Each device's record has two parts: -- **History** — normalized daily tokens and spend, model totals, and unknown-model names for sources - that are local to one Mac: Claude, Codex, Grok, Sakana, and OpenCode. Macs merge these into the - combined view. Cursor's history is already account-wide, so it is never added across Macs. -- **Snapshot** — that device's latest rendered usage state for every enabled provider (current - quotas, plans, balances, reset times, and refresh errors), with card titles resolved the way the - dashboard shows them — including your renames. Macs never display other Macs' snapshots; this - part exists for companion apps (such as the iOS app) that show live usage without holding any - provider credentials. - -Records never contain credentials, raw logs, or raw provider responses. When you disable a -provider, Runway immediately removes its peer contributions from the combined view and omits it -from this device's next record; its local cached snapshot remains. - -Runway combines the valid history payloads in memory and rebuilds Today, Yesterday, Last 30 Days, -Usage Trend, unknown-model warnings, and model breakdowns. The same combined spend rows feed the -dashboard, Total Spend, menu-bar pins, share cards, and the local HTTP API. Both `/v1/usage` and -`/v1/limits` read the same rendered snapshots; the former is the deprecated UI-oriented format and -the latter is the normalized format. Quotas, plans, balances, and provider errors on a Mac remain -that Mac's own values inside those snapshots. Rows retained in an older peer record are ignored once -they fall outside the same calendar window used by the local history scanners. - -Because every device only ever writes and deletes its own record, records cannot conflict: a save is -always the same device replacing its previous value. Readers fetch the whole zone and rebuild the -peer set from scratch, so a device that stops syncing simply disappears on the next load. - -This Mac updates its record after a five-minute refresh batch, a manual refresh, or a provider -enablement change, and checks for peer updates at the same moments plus on a five-minute poll. -CloudKit delivery is usually a matter of seconds, but it is eventually consistent — an offline Mac -catches up when it comes back. +- **History**: normalized daily tokens and spend, model totals, and unknown-model names for sources that are local to one Mac (Claude, Codex, Grok, Sakana, and OpenCode). Macs merge these into the combined view. Cursor's history is already account-wide, so it is never added across Macs. +- **Snapshot**: that device's latest rendered usage state for every enabled provider (current quotas, plans, balances, reset times, and refresh errors), with card titles as the dashboard shows them, including your renames. Macs never display other Macs' snapshots. This part exists for companion apps (such as the iOS app) that show live usage without holding any provider credentials. + +Records never contain credentials, raw logs, or raw provider responses. When you disable a provider, Runway removes its peer contributions from the combined view and omits it from this device's next record. Its local cached snapshot remains. + +Runway combines the valid history payloads in memory and rebuilds Today, Yesterday, Last 30 Days, Usage Trend, unknown-model warnings, and model breakdowns. The same combined spend rows feed the dashboard, Total Spend, menu-bar pins, share cards, and the local HTTP API. Quotas, plans, balances, and provider errors stay this Mac's own values. Rows in an older peer record are ignored once they fall outside the calendar window the local history scanners use. + +Every device only writes and deletes its own record, so records cannot conflict. Readers fetch the whole zone and rebuild the peer set from scratch, so a device that stops syncing disappears on the next load. + +This Mac updates its record after a five-minute refresh batch, a manual refresh, or a provider enablement change, and checks for peer updates at the same moments plus on a five-minute poll. CloudKit delivery is usually a matter of seconds, but it is eventually consistent. An offline Mac catches up when it comes back. ## Multiple accounts across Macs -Histories match by **account**, not by card name. Each device's record notes which account every -card belongs to (an opaque account/organization identifier — never an email), so the same account -merges into the same card everywhere, even when one Mac shows it as the main card and another as an -extra account card. +Histories match by **account**, not by card name. Each device's record notes which account every card belongs to (an opaque account or organization identifier, never an email), so the same account merges into the same card everywhere, even when one Mac shows it as the main card and another as an extra account card. -An account you use on another Mac but have no login for here doesn't become a card. It appears as -its own slice in **Total Spend**, named by its account code ("claude@ab12cd34"). So the number at -the top is the whole truth across your Macs, and you can tell several such accounts apart. That -code is the same id the account's card carries on any Mac it's signed in on (the synced record holds -no emails or names to label it with). The moment you log that account in locally, its card appears — -under that same id — with the full cross-machine history already attached. +An account you use on another Mac but have no login for here does not become a card. It appears as its own slice in **Total Spend**, named by its account code ("claude@ab12cd34"), so the number at the top covers all your Macs. That code is the same id the account's card carries on any Mac it is signed in on. When you log that account in locally, its card appears under that same id with the cross-machine history attached. -If a synced record cannot identify the account behind a main Claude or Codex card, Runway keeps its -spend in one remote family slice instead of attaching it to a different local account or dropping it -from Total Spend. +If a synced record cannot identify the account behind a main Claude or Codex card, Runway keeps its spend in one remote family slice instead of attaching it to a different local account or dropping it from Total Spend. -Devices running an older Runway read their own format but report this device's newer record as -"update Runway" — update both sides to sync multi-account machines. +Devices running an older Runway read their own format but report this device's newer record as "update Runway". Update both sides to sync multi-account machines. -Settings lists each valid device record with the time that device generated it. To remove a device -from the combined summary, turn sync off on that device; this deletes its record from iCloud. -Turning sync off also stops that device from reading peers and immediately returns every surface -there to local-only spend. Malformed records are ignored and reported in Settings and the app log. +Settings lists each valid device record with the time that device generated it. To remove a device from the combined summary, turn sync off on that device. This deletes its record from iCloud, stops that device from reading peers, and returns every surface there to local-only spend. Malformed records are ignored and reported in Settings and the app log. ## Development and release setup -Apple requires the iCloud container assignment to be present in the provisioning profile embedded in -the app, and the App ID must have the CloudKit capability. Runway uses separate containers so -development builds cannot write production data: +Apple requires the iCloud container assignment in the provisioning profile embedded in the app, and the App ID must have the CloudKit capability. Runway uses separate containers so development builds cannot write production data: - `com.mattstallone.runway.dev` uses `iCloud.com.mattstallone.runway.dev`. - `com.mattstallone.runway` uses `iCloud.com.mattstallone.runway`. -Development-signed builds additionally run against the container's **Development** CloudKit -environment (release builds use **Production**), so a dev build can never touch shipped data even -inside the same container. +Development-signed builds also run against the container's **Development** CloudKit environment (release builds use **Production**), so a dev build never touches shipped data even inside the same container. -CloudKit creates the `UsageHistory` zone, the `DeviceUsage` record type, and its `history` and -`snapshot` fields automatically the first time a development build writes — but only in the -Development environment. Production schemas are never auto-created: before the first release that -ships sync, and again after any schema change (a new record type or field), open the CloudKit -Console for the production container and use **Deploy Schema Changes** to promote the Development -schema to Production. A release build pointed at an undeployed Production container fails its first -write with the real CloudKit error in Settings and the app log. +CloudKit creates the `UsageHistory` zone, the `DeviceUsage` record type, and its `history` and `snapshot` fields the first time a development build writes, but only in the Development environment. Production schemas are never auto-created. Before the first release that ships sync, and after any schema change (a new record type or field), open the CloudKit Console for the production container and use **Deploy Schema Changes** to promote the Development schema to Production. A release build pointed at an undeployed Production container fails its first write with the CloudKit error in Settings and the app log. -Create a `MAC_APP_DEVELOPMENT` profile that includes every registered development Mac and a -`MAC_APP_DIRECT` profile for releases. Install the development profile on each included Mac. The -development build automatically selects the newest non-expired profile matching the development -bundle and iCloud container from Xcode's current profile directory or the legacy MobileDevice -directory: +Create a `MAC_APP_DEVELOPMENT` profile that includes every registered development Mac and a `MAC_APP_DIRECT` profile for releases. Install the development profile on each included Mac. The development build selects the newest non-expired profile matching the development bundle and iCloud container from Xcode's profile directory or the legacy MobileDevice directory: ```bash ./script/build_and_run.sh ``` -Set `ICLOUD_PROVISIONING_PROFILE=/path/to/profile.mobileprovision` only when you need to override -that automatic selection. An explicit missing path fails the build instead of silently producing an -app without iCloud access. +Set `ICLOUD_PROVISIONING_PROFILE=/path/to/profile.mobileprovision` only to override that selection. An explicit missing path fails the build instead of producing an app without iCloud access. -The release workflow reads the base64-encoded `MAC_APP_DIRECT` profile from the repository Actions -secret `APPLE_DEVELOPER_ID_ICLOUD_PROFILE`. Keep the original provisioning profiles and signing `.p12` -in a password manager, never in the repository. A provisioning profile contains certificates and -entitlements rather than private keys, but treating it as a signing asset keeps rotation predictable. +The release workflow reads the base64-encoded `MAC_APP_DIRECT` profile from the repository Actions secret `APPLE_DEVELOPER_ID_ICLOUD_PROFILE`. Keep the original provisioning profiles and signing `.p12` in a password manager, never in the repository. -To inspect the records written by a running build, use the CloudKit Console -() — pick the container, then **Data → Private Database → zone -`UsageHistory` → record type `DeviceUsage`**, in the **Development** environment for dev builds. -The same query works from the command line once `xcrun cktool save-token` has stored a token: +To inspect the records written by a running build, use the CloudKit Console (): pick the container, then **Data → Private Database → zone `UsageHistory` → record type `DeviceUsage`**, in the **Development** environment for dev builds. The same query works from the command line once `xcrun cktool save-token` has stored a token: ```bash xcrun cktool query-records \ @@ -122,6 +59,4 @@ xcrun cktool query-records \ --zone-name UsageHistory --record-type DeviceUsage ``` -No record is expected when sync is off, the app is signed without the matching profile, or the first -write has not completed. The Settings error and app log distinguish those cases (including a Mac not -signed into iCloud); the spinner only appears while a read or write is actually in progress. +No record is expected when sync is off, the app is signed without the matching profile, or the first write has not completed. The Settings error and app log distinguish those cases, including a Mac not signed into iCloud. The spinner only appears while a read or write is in progress. diff --git a/docs/ios-app.md b/docs/ios-app.md index 43256683c..52aacdf9a 100644 --- a/docs/ios-app.md +++ b/docs/ios-app.md @@ -1,54 +1,25 @@ # iOS Companion App -RunwayMobile (in `ios/`) is a read-only iPhone/iPad viewer for the usage your Macs publish through -[iCloud Sync](icloud-sync.md). It holds no provider credentials and never writes to iCloud: it -fetches every device record from Runway's private CloudKit database and renders it. +RunwayMobile (in `ios/`) is a read-only iPhone and iPad viewer for the usage your Macs publish through [iCloud Sync](icloud-sync.md). It holds no provider credentials and never writes to iCloud. It fetches every device record from Runway's private CloudKit database and renders it. The dashboard shows: -- **Across Your Macs** — Today, Yesterday, and Last 30 Days spend/token tiles plus a usage trend, - day-summed from every device's history payload (the same additive model the Mac uses). -- **One section per Mac** — that device's live snapshot, one collapsed row per provider showing the - account name, its plan beside it, and the percent of the weekly quota still left at the trailing - edge (blank for providers with no weekly meter). Expanding a row reveals its rendered metrics - (quota meters with reset countdowns, spend tiles, status badges, charts), warnings, and refresh - errors; the record's age sits in the section header. A Mac whose Keychain login is simply waiting - to be connected there shows the same muted key glyph the Mac uses — neutral, not a warning - triangle — since only that Mac can load it. +- **Across Your Macs**: Today, Yesterday, and Last 30 Days spend and token tiles plus a usage trend, summed per day from every device's history (the same additive model the Mac uses). +- **One section per Mac**: that device's live snapshot, one collapsed row per provider showing the account name, its plan, and the percent of the weekly quota still left (blank for providers with no weekly meter). Expanding a row shows its metrics (quota meters with reset countdowns, spend tiles, status badges, charts), warnings, and refresh errors. The record's age sits in the section header. A Mac whose Keychain login is waiting to be connected shows the same muted key glyph the Mac uses, since only that Mac can load it. -Data refreshes on launch, on returning to the foreground, and with pull-to-refresh. Liveness is -bounded by the Macs' five-minute publish cadence. +Data refreshes on launch, on returning to the foreground, and with pull-to-refresh. Liveness is bounded by the Macs' five-minute publish cadence. ## Widgets -The app ships lock screen and home screen widgets ("Across Your Macs") showing the combined -totals: Today on every family, plus Yesterday, Last 30 Days, and the usage trend where the size -allows. Lock screen families are the inline line, the circular Today tile, and the rectangular -Today/30 Days list; home screen families are small and medium. - -Each widget instance can show either cost (the default: dollars when priced, token counts when -not) or token counts — long-press the widget and choose Edit Widget (on the lock screen, tap the -widget while customizing), so one slot can show spend while another shows tokens. - -The widget extension reads the same CloudKit data itself on WidgetKit's budgeted schedule -(roughly every half hour), and the app also asks widgets to reload whenever it fetches fresher -data in the foreground. The widget caches the last good numbers, so a failed refresh shows slightly -stale totals instead of an empty widget. The cache stays honest: cached entries show their fetch -age, and day tiles re-anchor to the current date, so a cached "Today" never mislabels an older -day. The cache is tied to the iCloud account and cleared on sign-out, so another account's -numbers can never appear. Like the dashboard, incomplete totals are never silent: a warning -triangle appears when payloads were unreadable or models unpriced, and an all-unreadable -container says "Update this app". Signed-out, restricted, waiting-for-first-sync, and -unreachable states each show a short notice instead of numbers. +The app ships lock screen and home screen widgets ("Across Your Macs") with the combined totals: Today on every family, plus Yesterday, Last 30 Days, and the usage trend where the size allows. Lock screen families are the inline line, the circular Today tile, and the rectangular Today/30 Days list. Home screen families are small and medium. + +Each widget can show cost (the default: dollars when priced, token counts when not) or token counts. Long-press the widget and choose Edit Widget (on the lock screen, tap the widget while customizing), so one slot can show spend while another shows tokens. + +The widget extension reads the same CloudKit data on WidgetKit's schedule (roughly every half hour), and the app asks widgets to reload whenever it fetches fresher data in the foreground. The widget caches the last good numbers, so a failed refresh shows slightly stale totals instead of an empty widget. Cached entries show their fetch age, and day tiles re-anchor to the current date, so a cached "Today" never labels an older day. The cache is tied to the iCloud account and cleared on sign-out. Incomplete totals are never silent: a warning triangle appears when payloads were unreadable or models unpriced, and an all-unreadable container says "Update this app". Signed-out, restricted, waiting-for-first-sync, and unreachable states each show a short notice instead of numbers. ## Wire contract -The app decodes the versioned payloads the Mac writes (`runway.history.v2`, -`runway.snapshot.v1`) with tolerant decoders in `ios/Shared/SyncWire.swift` (shared with the -widget extension, alongside the CloudKit reader in `UsageSyncReader.swift`): unknown JSON -keys and unknown row types are ignored, so additive Mac-side changes don't break older phones. A -schema *bump* shows an "update Runway and this app" notice instead of wrong numbers. When the Mac -payloads change shape, update `SyncWire.swift` to match. +The app decodes the versioned payloads the Mac writes (`runway.history.v2`, `runway.snapshot.v1`) with tolerant decoders in `ios/Shared/SyncWire.swift` (shared with the widget extension, alongside the CloudKit reader in `UsageSyncReader.swift`). Unknown JSON keys and unknown row types are ignored, so additive Mac-side changes do not break older phones. A schema bump shows an "update Runway and this app" notice instead of wrong numbers. When the Mac payloads change shape, update `SyncWire.swift` to match. ## Building @@ -59,60 +30,19 @@ xcodebuild -project ios/RunwayMobile.xcodeproj -target RunwayMobile \ -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build ``` -That unsigned simulator build is for compile verification only — a launch aborts at CloudKit -setup, since it carries no iCloud entitlements (iOS offers no public API to probe for them -first). Run the app from Xcode instead, which signs simulator and device builds alike. +That unsigned simulator build is for compile verification only. A launch aborts at CloudKit setup because it carries no iCloud entitlements. Run the app from Xcode instead, which signs simulator and device builds. -Debug builds read the development container (`iCloud.com.mattstallone.runway.dev`, Development -environment — the same place dev Mac builds write); Release builds read the production container. -The app and widget App IDs (`com.mattstallone.runway.mobile` and -`com.mattstallone.runway.mobile.widgets`) both need the CloudKit capability with both containers; -signing is automatic with the development team. On device, the app must be signed into the same -iCloud account as the Macs. +Debug builds read the development container (`iCloud.com.mattstallone.runway.dev`, Development environment, the same place dev Mac builds write). Release builds read the production container. The app and widget App IDs (`com.mattstallone.runway.mobile` and `com.mattstallone.runway.mobile.widgets`) both need the CloudKit capability with both containers. Signing is automatic with the development team. On device, the app must be signed into the same iCloud account as the Macs. ## Releasing (TestFlight) -The iOS app ships from the same `v*` tag as the Mac app — when the release actually touches it. -The release workflow calls the dedicated `.github/workflows/release-ios.yml` pipeline. Its -"iOS Gate" job (`script/testflight_gate.mjs`) checks what changed since the last build the external -TestFlight testers actually received. A Mac-only release skips the iOS jobs entirely: -every upload is a new version, and each one goes through a fresh Beta App Review and pushes a -pointless update at testers. When the gate says ship, the "iOS TestFlight" job archives -the app, signs it, and uploads it to App Store Connect, which serves it to internal TestFlight -testers once Apple finishes processing. A follow-up -"TestFlight External" job then waits for that processing, adds the build to the external tester -group(s), and submits it for Beta App Review — external testers receive it when Apple approves -(`script/testflight_distribute.mjs` does that through the App Store Connect API). Testers install -and update through the TestFlight app — there is no Sparkle feed on iOS, and no notarization -either (the App Store Connect upload plays that role). - -The gate ships when anything under `ios/`, the dedicated iOS release workflow, or a TestFlight -pipeline script changed since the baseline build. Changes to the separate macOS release job do not -count. The baseline is the newest processed build present in every external TestFlight group. Its -version names the tag it was built from, so a failed upload or a failed external distribution never -advances the baseline, and its changes cannot be stranded by a later Mac-only tag. The gate also -ships when the baseline build is older than 60 days: TestFlight builds expire 90 days after upload, -so an unchanged app still re-ships before testers' installs go dark. Beta App Review approval is -deliberately not part of the baseline: it stays pending for up to a day after every ship, and a wait -for it re-submits identical builds. This means TestFlight can lag the Mac version (say, 0.8.9 on the -Mac while TestFlight shows 0.8.5); that is expected and harmless — a Mac-side change that matters to -the phone must update the wire decoders in `ios/`, which trips the gate. To ship iOS regardless, run -the Release workflow manually with the `force_ios` input checked. - -- The version is the tag (`v0.7.1` → `0.7.1`) and the build number is the git commit count, both - injected at build time — the `MARKETING_VERSION` in the Xcode project is never bumped by hand. - TestFlight rejects a reused build number for the same version, so rerunning a tag that already - uploaded fails at the upload step; tag a new patch version instead. -- Signing is manual: an Apple Distribution certificate and two App Store provisioning profiles - (one for the app, one for the widget extension — each bundle ID needs its own) stored as - repository secrets, the same pattern as the Mac release's Developer ID cert and iCloud - profile. The App Store Connect API key (App Manager role) only authenticates the upload and the - TestFlight distribution calls — cloud signing does not work with API keys below Admin. -- `script/release_ios.sh` is the whole build; run it locally with `SKIP_TESTFLIGHT_UPLOAD=1` to get - a signed `.ipa` in `dist/ios/` without uploading. -- The upload can never be repeated, but the external-distribution job is idempotent: if it fails - (say, before the one-time Test Information is filled in), rerun just that job with - `gh run rerun --failed` — the uploaded build is untouched. - -The one-time App Store Connect setup (app record, tester group, key role) is documented in the -release-swift skill under `.agents/skills/release-swift/`. +The iOS app ships from the same `v*` tag as the Mac app, when the release touches it. The release workflow calls `.github/workflows/release-ios.yml`. Its "iOS Gate" job (`script/testflight_gate.mjs`) checks what changed since the last build the external TestFlight testers received. A Mac-only release skips the iOS jobs, because every upload is a new version that goes through a fresh Beta App Review and pushes an update at testers. When the gate says ship, the "iOS TestFlight" job archives the app, signs it, and uploads it to App Store Connect, which serves it to internal testers once Apple finishes processing. The "TestFlight External" job then waits for that processing, adds the build to the external tester group(s), and submits it for Beta App Review (`script/testflight_distribute.mjs`). External testers receive it when Apple approves. Testers install and update through the TestFlight app. There is no Sparkle feed on iOS and no notarization. + +The gate ships when anything under `ios/`, the iOS release workflow, or a TestFlight pipeline script changed since the baseline build. Changes to the macOS release job do not count. The baseline is the newest processed build present in every external TestFlight group. Its version names the tag it was built from, so a failed upload or failed external distribution never advances the baseline. The gate also ships when the baseline build is older than 60 days, because TestFlight builds expire 90 days after upload. Beta App Review approval is not part of the baseline: it stays pending for up to a day after every ship, and waiting for it would re-submit identical builds. TestFlight can therefore lag the Mac version (say, 0.8.9 on the Mac while TestFlight shows 0.8.5). That is expected. A Mac-side change that matters to the phone must update the wire decoders in `ios/`, which trips the gate. To ship iOS regardless, run the Release workflow manually with the `force_ios` input checked. + +- The version is the tag (`v0.7.1` → `0.7.1`) and the build number is the git commit count, both injected at build time. `MARKETING_VERSION` in the Xcode project is never bumped by hand. TestFlight rejects a reused build number for the same version, so rerunning a tag that already uploaded fails at the upload step. Tag a new patch version instead. +- Signing is manual: an Apple Distribution certificate and two App Store provisioning profiles (one for the app, one for the widget extension) stored as repository secrets, the same pattern as the Mac release's Developer ID cert and iCloud profile. The App Store Connect API key (App Manager role) only authenticates the upload and the TestFlight distribution calls. Cloud signing does not work with API keys below Admin. +- `script/release_ios.sh` is the whole build. Run it locally with `SKIP_TESTFLIGHT_UPLOAD=1` to get a signed `.ipa` in `dist/ios/` without uploading. +- The upload can never be repeated, but the external-distribution job is idempotent. If it fails (say, before the one-time Test Information is filled in), rerun just that job with `gh run rerun --failed`. The uploaded build is untouched. + +The one-time App Store Connect setup (app record, tester group, key role) is in [Releasing](releasing.md). diff --git a/docs/local-http-api.md b/docs/local-http-api.md index a99087146..e953c4c96 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -1,57 +1,44 @@ # Local HTTP API -Runway exposes a read-only HTTP API on the loopback interface so other local apps can consume the same usage data shown in the menu bar. +Runway exposes a read-only HTTP API on the loopback interface so other local apps can read the same usage data shown in the menu bar. **Base URL:** `http://127.0.0.1:6736` -The server starts automatically with the app. If the port is already in use, the app disables the API for that session and notes it in the log. +The server starts with the app. If the port is already in use, the app disables the API for that session and notes it in the log. ## Routes ### `GET /v1/limits` -Returns a machine-facing envelope for all **enabled** providers. Providers and resources are keyed by -stable IDs; values are raw scalars with explicit units. This is the preferred route for new integrations -and the exact format printed by the `runway` CLI. +Returns an envelope for all **enabled** providers. Providers and resources are keyed by stable IDs. Values are raw scalars with explicit units. Use this route for new integrations. It is the exact format the `runway` CLI prints. ### `GET /v1/limits/:id` -Returns the same envelope containing every provider the ID names. It works for disabled providers too. -Matching is plain string comparison. An exact provider ID names that provider. A family ID -(`claude`, `codex`) names every account card of that family. With one account, the family ID names -exactly that one card. There is no aliasing or "pick the right account" logic; the same request -always names the same providers. +Returns the same envelope for every provider the ID names, including disabled providers. Matching is plain string comparison. An exact provider ID names that provider. A family ID (`claude`, `codex`) names every account card of that family. With one account, the family ID names that one card. The same request always names the same providers. -- **200 OK** — limits envelope with every matched provider that has data (an `errors` entry appears - when a refresh failed; a matched provider with no data yet simply has no entry). -- **404 Not Found** — the ID names no known provider and no family. +- **200 OK**: limits envelope with every matched provider that has data. An `errors` entry appears when a refresh failed. A matched provider with no data yet has no entry. +- **404 Not Found**: the ID names no known provider and no family. ### `GET /v1/usage` -Returns the legacy UI-oriented snapshots for all **enabled** providers, in your dashboard order. Existing -consumers remain supported while this route is deprecated; new consumers must use `/v1/limits`. +Returns the older UI-oriented snapshots for all **enabled** providers, in your dashboard order. This route is deprecated but still supported. New consumers should use `/v1/limits`. -Both routes read the same rendered provider snapshots. When iCloud Sync is on, that means they both see -the same iCloud-combined usage as the dashboard; `/v1/usage` returns the old UI-oriented shape, while -`/v1/limits` projects the data into stable resource IDs and raw scalar values. +Both routes read the same rendered snapshots. With iCloud Sync on, both see the same iCloud-combined usage as the dashboard. `/v1/usage` returns the old UI shape, and `/v1/limits` projects the data into stable resource IDs and raw values. -- **200 OK** — JSON array (empty `[]` when the app has not fetched anything yet). +- **200 OK**: JSON array (empty `[]` when the app has not fetched anything yet). ### `GET /v1/usage/:id` -Returns the latest snapshots for every provider the ID names (same matching as `/v1/limits/:id`). -Works for disabled providers too. +Returns the latest snapshots for every provider the ID names (same matching as `/v1/limits/:id`). Works for disabled providers too. -- **200 OK** — JSON array, one snapshot per matched provider that has one (`[]` when none do yet). -- **404 Not Found** — the ID names no known provider and no family. +- **200 OK**: JSON array, one snapshot per matched provider that has one (`[]` when none do yet). +- **404 Not Found**: the ID names no known provider and no family. -> **Breaking change:** this route previously returned a single JSON object and `204` when the -> provider had no snapshot. It now always returns an array, so the shape stays identical whether an -> ID names one provider or a whole account family. +> **Breaking change:** this route used to return a single JSON object and `204` when the provider had no snapshot. It now always returns an array, so the shape is the same whether an ID names one provider or a whole family. ### Everything else -Methods other than `GET`/`OPTIONS` return **405**; unknown routes return **404**. When the server is already handling its maximum of 16 concurrent connections, requests get **503** — back off and retry. +Methods other than `GET` and `OPTIONS` return **405**. Unknown routes return **404**. When the server is already handling its maximum of 16 concurrent connections, requests get **503**. Back off and retry. ## Limits response shape @@ -89,17 +76,9 @@ Methods other than `GET`/`OPTIONS` return **405**; unknown routes return **404** } ``` -`kind` is `consumption` (`used`) or `balance` (`available`). Bounded consumption also carries `limit`, -`remaining`, and a 0–1 `utilization`. Reset, window, expiry-list, and `estimated` fields appear only when -the provider supplies that meaning. A provider or resource with no current value is omitted rather than -invented as zero. `expiresAt` is always `fetchedAt` plus the same five-minute freshness interval used by -the app and CLI; `stale` says whether that instant has passed. Refresh failures appear in `errors` as -`{"providerId":"…","message":"…"}` while a last-good provider snapshot remains available. -For bounded progress resources, `unit` follows the provider's live metric format. For example, Cursor -`totalUsage` is `percent` on percentage-based plans, `requests` on request-based Enterprise plans, and -`usd` when Cursor reports a dollar pool. Copilot `premiumCredits` is `percent` on paid plans and a -`credits` count on org-managed seats that only report personal `credits_used`. OpenCode `session`, -`weekly`, and `monthly` are `percent`. +`kind` is `consumption` (`used`) or `balance` (`available`). Bounded consumption also carries `limit`, `remaining`, and a 0 to 1 `utilization`. Reset, window, expiry-list, and `estimated` fields appear only when the provider supplies that meaning. A provider or resource with no current value is omitted, never reported as zero. `expiresAt` is `fetchedAt` plus the five-minute freshness interval used by the app and CLI. `stale` says whether that instant has passed. Refresh failures appear in `errors` as `{"providerId":"…","message":"…"}` while a last-good snapshot remains available. + +For bounded resources, `unit` follows the provider's live metric format. Cursor `totalUsage` is `percent` on percentage-based plans, `requests` on request-based Enterprise plans, and `usd` when Cursor reports a dollar pool. Copilot `premiumCredits` is `percent` on paid plans and a `credits` count on org-managed seats that only report personal `credits_used`. OpenCode `session`, `weekly`, and `monthly` are `percent`. ### Public resources @@ -118,8 +97,7 @@ For bounded progress resources, `unit` follows the provider's live metric format | Sakana Fugu | `session`, `weekly` | | Z.ai | `session`, `weekly`, `webSearches` | -Charts, colors, subtitles, formatted badges, layout state, and historical spend periods stay out of this -contract. Codex's combined Credits UI row becomes two scalar resources: `credits` and `creditValue`. +Charts, colors, subtitles, formatted badges, layout state, and historical spend periods are not part of this contract. Codex's combined Credits UI row becomes two resources: `credits` and `creditValue`. ## Legacy usage response shape @@ -168,11 +146,11 @@ contract. Codex's combined Credits UI row becomes two scalar resources: `credits } ``` -Line types are `progress`, `text`, `badge`, and `barChart`. A `barChart` line carries a `points` array — one `{ label, value, valueLabel? }` per day, oldest first — plus an optional `note`; `value` is the day's token count, `valueLabel` its pre-formatted readout, and `label` a localized month/day (e.g. "Mar 25"). `fetchedAt` is when the snapshot was last fetched successfully (ISO 8601). +Line types are `progress`, `text`, `badge`, and `barChart`. A `barChart` line carries a `points` array, one `{ label, value, valueLabel? }` per day, oldest first, plus an optional `note`. `value` is the day's token count, `valueLabel` its formatted readout, and `label` a localized month and day (for example "Mar 25"). `fetchedAt` is when the snapshot was last fetched successfully (ISO 8601). -This API does not yet include the in-app model breakdown that appears when you hover a spend row. Spend rows continue to serialize as the same `text` lines so existing local integrations keep their current shape. +This API does not include the model breakdown that appears when you hover a spend row. Spend rows serialize as `text` lines. -In both response shapes, `displayName` is the card's current name — if you renamed a card in the app, the rename shows here too. Match on `providerId` (or the envelope key), never on the name. +In both shapes, `displayName` is the card's current name, including any rename. Match on `providerId` (or the envelope key), never on the name. ## Errors @@ -184,10 +162,10 @@ Codes: `provider_not_found`, `not_found`, `method_not_allowed`, `server_busy`. ## CORS and privacy -All responses include permissive CORS headers (`Access-Control-Allow-Origin: *`, methods `GET, OPTIONS`). `OPTIONS` requests return **204** for preflight. +All responses include permissive CORS headers (`Access-Control-Allow-Origin: *`, methods `GET, OPTIONS`). `OPTIONS` requests return **204**. -The server only listens on the loopback interface (`127.0.0.1`), so it is not reachable from other machines on your network. Because the CORS header is permissive, though, a web page open in your browser can read your usage snapshots from this API while the app is running. The data exposed is the same usage numbers shown in the menu bar — no credentials or tokens are ever served. The header stays permissive so existing local integrations keep working. +The server listens only on `127.0.0.1`, so other machines on your network cannot reach it. Because the CORS header is permissive, a web page open in your browser can read your usage from this API while the app is running. The data is the same usage numbers shown in the menu bar. Credentials and tokens are never served. The header stays permissive so existing local integrations keep working. ## Caching behavior -The API serves whatever the app is showing: only successful fetches replace data, so a failed refresh never blanks the API — you keep getting the last good snapshot. See [Refreshing & caching](refreshing.md). +The API serves whatever the app is showing. Only successful fetches replace data, so a failed refresh never blanks the API. See [Refreshing & caching](refreshing.md). diff --git a/docs/logging.md b/docs/logging.md index 9984b35a0..22db6b046 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,9 +1,6 @@ # Logging -Runway keeps a file log so you can capture what the app was doing and share it with support when -something misbehaves. Lines at or above your chosen level also go to the macOS unified log, so raising -the level to Debug surfaces the extra detail in both places (see [Debugging](debugging.md) for -`log stream`). +Runway keeps a file log so you can see what the app was doing and share it with support. Lines at or above your chosen level also go to the macOS unified log, so raising the level to Debug adds detail in both places (see [Debugging](debugging.md) for `log stream`). ## Where the log file lives @@ -11,42 +8,32 @@ the level to Debug surfaces the extra detail in both places (see [Debugging](deb ~/Library/Logs/Runway/Runway.log ``` -The easiest way to grab it: open Settings -> Advanced and use **Copy Log Path** (puts the path on the -clipboard) or **Reveal in Finder** (selects the file in a Finder window). No Terminal needed. +The easiest way to grab it: open Settings → Advanced and use **Copy Log Path** or **Reveal in Finder**. -## Changing the log level (Settings -> Advanced) +## Changing the log level (Settings → Advanced) -The **Log Level** picker controls how much detail is written. Your choice persists across launches and -takes effect immediately — no restart. +The **Log Level** picker controls how much detail is written. Your choice persists across launches and takes effect immediately. | Level | What it captures | |---|---| | Error | Only failures. | -| Warning | Failures plus things that look wrong but recovered. | -| Info | The normal story: refresh start/end, per-provider results, cache and auth milestones. | +| Warning | Failures plus things that looked wrong but recovered. | +| Info | Refresh start and end, per-provider results, cache and auth milestones. | | Debug | Everything, including per-request and per-cache-check detail. | -The release default is **Info** — quiet but useful. **Debug** is opt-in; turn it on only while -reproducing a problem, since it is much noisier. +The release default is **Info**. Turn on **Debug** only while reproducing a problem. It is much noisier. -If a local usage log exists but cannot be read, Runway writes one warning and skips it for that -refresh. It does not repeat the warning every five minutes; it warns again only if the file recovers -and later becomes unreadable again. +If a local usage log exists but cannot be read, Runway writes one warning and skips it for that refresh. It warns again only if the file recovers and later becomes unreadable again. -Any provider refresh that takes 10 seconds or longer writes a Warning-level `[refresh]` line with the -provider ID, elapsed milliseconds, and threshold. This is visible at the default Info setting, so a -slow local-log scan or network call can be identified from a normal support log without reproducing it -with Debug enabled. The warning is diagnostic only: other provider cards still update independently, -and the slow provider is allowed to finish. +Any provider refresh that takes 10 seconds or longer writes a Warning-level `[refresh]` line with the provider ID, elapsed milliseconds, and threshold. This is visible at the default Info level, so a slow log scan or network call can be found in a normal support log. The warning is diagnostic only. Other provider cards still update independently, and the slow provider is allowed to finish. ## Subsystem tags -Every line is prefixed with a bracketed tag so the log is easy to grep: +Every line starts with a bracketed tag so the log is easy to grep: -`[refresh]` `[cache]` `[http]` `[auth]` `[keychain]` `[menubar]` `[updates]` `[config]` -`[subprocess]` `[localapi]`, plus per-provider tags like `[plugin:claude]` and `[auth:claude]`. +`[refresh]` `[cache]` `[http]` `[auth]` `[keychain]` `[menubar]` `[updates]` `[config]` `[subprocess]` `[localapi]`, plus per-provider tags like `[plugin:claude]` and `[auth:claude]`. -For example, to follow just the refresh cycle: +To follow just the refresh cycle: ```sh grep '\[refresh\]' ~/Library/Logs/Runway/Runway.log @@ -54,19 +41,10 @@ grep '\[refresh\]' ~/Library/Logs/Runway/Runway.log ## What is never logged -Secrets never reach the log. The app redacts access/refresh tokens, cookies, session tokens, and API -keys before it writes any line. A sensitive value becomes `first4...last4`, or `[REDACTED]` when it is -too short to mask safely. Filesystem paths under your home directory become `[PATH]`. The app never -logs a response body in full. On an HTTP error, it can record a redacted, truncated (≤500 byte) -preview at Debug to aid diagnosis. The preview goes through the same redaction first. A test suite -guards the redaction rules. +Secrets never reach the log. The app redacts access and refresh tokens, cookies, session tokens, and API keys before it writes any line. A sensitive value becomes `first4...last4`, or `[REDACTED]` when it is too short to mask safely. Paths under your home directory become `[PATH]`. The app never logs a full response body. On an HTTP error it can record a redacted, truncated preview of at most 500 bytes at Debug. A test suite guards the redaction rules. ## File size cap -The log is capped at ~10 MB. When it fills up, the app rotates the current file to `Runway.1.log` and -starts a fresh `Runway.log`. A long-running session uses at most ~20 MB across the live file and one -archive, so it can never fill your disk. If a previous session left an oversize file, the app rotates -it once at launch. +The log is capped at about 10 MB. When it fills, the app rotates the current file to `Runway.1.log` and starts a fresh `Runway.log`. A long session uses at most about 20 MB across the live file and one archive. If a previous session left an oversize file, the app rotates it once at launch. -> Note: the dev build and a released build both write to the same `Runway.log`. Running them at the -> same time interleaves their lines — fine for normal use, worth knowing if you debug both at once. +> Note: the dev build and a released build both write to the same `Runway.log`. Running both at once interleaves their lines. diff --git a/docs/memory-explorer.md b/docs/memory-explorer.md index fcb9c2959..aee2ace65 100644 --- a/docs/memory-explorer.md +++ b/docs/memory-explorer.md @@ -1,43 +1,43 @@ # Memory Explorer -AI coding agents keep persistent memory and instruction files on disk — Claude Code's per-project memories, Codex's `AGENTS.md`, Gemini's `GEMINI.md`, and so on — with no single place to see or change them. The Memory window is that place: it finds every memory home on your Mac, shows what's inside, and lets you read, edit, create, and delete the files directly. Everything happens on your Mac; nothing is sent anywhere. +AI coding agents keep memory and instruction files on disk: Claude Code's per-project memories, Codex's `AGENTS.md`, Gemini's `GEMINI.md`, and so on. The Memory window finds every memory home on your Mac, shows what is inside, and lets you read, edit, create, and delete the files. Everything happens on your Mac. Nothing is sent anywhere. -Open it from the popover footer's **gear** menu (**Memory**), with ⌘M while the popover is showing, or by right-clicking the menu bar icon and choosing **Memory**. It opens in its own resizable window, remembers its size and position, and closes with the red close button, Esc, ⌘W, or ⌘Q — ⌘Q closes only this window; Runway keeps running in the menu bar. Like Settings, the window only exists while it's open. +Open it from the popover footer's **gear** menu (**Memory**), with ⌘M while the popover is showing, or by right-clicking the menu bar icon and choosing **Memory**. It opens in its own resizable window, remembers its size and position, and closes with the red close button, Esc, ⌘W, or ⌘Q. ⌘Q closes only this window. Runway keeps running in the menu bar. Like Settings, the window only exists while it is open. -Discovery is not tied to which providers you have turned on in Runway — if a harness left memory files on disk, they appear, even if you're logged out of that tool. Harnesses with nothing on disk simply don't show up. The **Refresh** button re-scans at any time. If a scan runs out of time or some folders can't be read, the sidebar says so instead of presenting a partial list as complete. +Discovery does not depend on which providers you have on in Runway. If a harness left memory files on disk, they appear, even if you are logged out of that tool. Harnesses with nothing on disk do not show up. The **Refresh** button re-scans. If a scan runs out of time or some folders cannot be read, the sidebar says so instead of presenting a partial list as complete. -If you renamed a Claude or Codex account card (right-click the card in the popover → **Rename…**), the sidebar shows that custom name for the matching home, and it updates live when you rename again. +If you renamed a Claude or Codex account card (right-click the card in the popover → **Rename…**), the sidebar shows that name for the matching home and updates when you rename again. ## What each harness supports | Harness | What appears | Editing | |---|---|---| -| Claude Code | `CLAUDE.md` plus each project's memory folder — its `MEMORY.md` index and individual memory files — across every config home (`~/.claude`, `~/.claude-personal`, any `CLAUDE_CONFIG_DIR`, …) | Full: edit, create, and delete memories | -| Codex | `AGENTS.md` and legacy `memories/*.md` files, plus rows from its memory database (`memories_1.sqlite`) | Files are editable; database rows are read-only | +| Claude Code | `CLAUDE.md` plus each project's memory folder (its `MEMORY.md` index and individual memory files) across every config home (`~/.claude`, `~/.claude-personal`, any `CLAUDE_CONFIG_DIR`) | Full: edit, create, and delete memories | +| Codex | `AGENTS.md` and legacy `memories/*.md` files, plus rows from its memory database (`memories_1.sqlite`) | Files are editable. Database rows are read-only | | Gemini | `GEMINI.md` | Editable | | Grok | Its `memory/` folder (global and per-project `MEMORY.md`), when the memory feature is enabled | Editable | -Project folders show a decoded project path where possible (e.g. `/Users/you/Developer/myapp`); when the path can't be verified on disk, the raw folder name shows instead. This is display-only — the files underneath are always the real ones. +Project folders show a decoded project path where possible (for example `/Users/you/Developer/myapp`). When the path cannot be verified on disk, the raw folder name shows instead. This is display-only. The files underneath are always the real ones. ## The four states -Each source is in one of four states, and the sidebar ranks them: sources with content first, then homes with nothing in them yet, then harnesses whose memory feature is off. Within each group the usual provider order applies (Claude, Codex, then alphabetical). Every section collapses by clicking its header. Sources with content start expanded; so does any source with a problem to show (an unreadable file, a scan failure note), because a collapsed section would hide its explanation. The rest start collapsed — their badge already says what's going on. +Each source is in one of four states. The sidebar ranks them: sources with content first, then homes with nothing in them yet, then harnesses whose memory feature is off. Within each group the usual provider order applies (Claude, Codex, then alphabetical). Click a section header to collapse it. Sources with content start expanded, and so does any source with a problem to show (an unreadable file, a scan failure). The rest start collapsed, because their badge already says what is going on. -- **Ready** — memory files exist and have content. This is the normal state, so it shows no badge. -- **Empty** — the file exists but is blank (common for a fresh `GEMINI.md`). You can start writing right away. -- **No File** — the harness's home is there but its instruction file isn't, and there are no other memory files either. -- **Memory Disabled** — the harness is installed but its memory feature is off (for example, Codex with `use_memories = false` in its config, or Grok without a `[memory]` section in its config). The sidebar says which switch is off and where it lives. Runway shows the state; it never flips the feature on for you. A Grok home with memory turned on but no files yet shows **No File** instead, with the usual create option. +- **Ready**: memory files exist and have content. No badge. +- **Empty**: the file exists but is blank (common for a fresh `GEMINI.md`). You can start writing right away. +- **No File**: the harness's home is there but its instruction file is not, and there are no other memory files. +- **Memory Disabled**: the harness is installed but its memory feature is off (for example, Codex with `use_memories = false` in its config, or Grok without a `[memory]` section). The sidebar says which switch is off and where it lives. Runway shows the state and never turns the feature on for you. A Grok home with memory on but no files yet shows **No File** instead, with the usual create option. -An expected instruction file that doesn't exist yet can be created in place from the window — the **Create Instruction File** row appears whenever the file is absent, whether the source shows **No File** or is **Ready** off other memory files. +An instruction file that does not exist yet can be created from the window. The **Create Instruction File** row appears whenever the file is absent, whether the source shows **No File** or is **Ready** from other memory files. ## Editing and saving -Nothing saves automatically. These files are shared with live agent processes, so changes only land when you press **Save** (⌘S) — the button lights up when you have unsaved edits, and closing the window, switching documents, pressing **Refresh**, or quitting Runway with unsaved edits asks first (Save / Discard / Cancel). Every save — including one chosen from those prompts — first checks whether the file changed (or disappeared) on disk; if it did, the editor shows the Reload / Overwrite banner instead of writing, and only the banner's **Overwrite** writes over a moved file. +Nothing saves automatically. These files are shared with live agent processes, so changes only land when you press **Save** (⌘S). The button lights up when you have unsaved edits. Closing the window, switching documents, pressing **Refresh**, or quitting Runway with unsaved edits asks first (Save, Discard, or Cancel). Every save first checks whether the file changed or disappeared on disk. If it did, the editor shows the Reload / Overwrite banner instead of writing. Only the banner's **Overwrite** writes over a moved file. Because an agent can write the same file while you have it open, Runway checks the file on disk before saving and whenever the window comes back to the front: -- **File changed on disk, you haven't edited** — your view silently reloads to the new content. -- **File changed on disk, you have unsaved edits** — a banner offers **Reload** (drop your edits, load the disk version) or **Overwrite** (save your version anyway). +- **File changed on disk, no unsaved edits**: your view reloads to the new content. +- **File changed on disk, unsaved edits**: a banner offers **Reload** (drop your edits, load the disk version) or **Overwrite** (save your version anyway). Saving preserves the file's existing permissions. @@ -45,15 +45,15 @@ Saving preserves the file's existing permissions. Claude Code keeps a `MEMORY.md` index alongside its memory files, and Runway keeps the two in sync: -- **New Memory…** on a project asks for a title, description, and type, then writes the new file *and* adds its line to the index (creating `MEMORY.md` first if the project doesn't have one). -- **Delete** on a memory (with confirmation) removes the file *and* drops its line from the index. +- **New Memory…** on a project asks for a title, description, and type, then writes the new file and adds its line to the index (creating `MEMORY.md` first if needed). +- **Delete** on a memory (with confirmation) removes the file and drops its line from the index. -Editing a memory's text doesn't rewrite its index line — if you change a title by hand, update the index line too, just as you would when editing outside Runway. +Editing a memory's text does not rewrite its index line. If you change a title by hand, update the index line too. ## The read-only database view -Codex also distills memories into a local database. Runway lists those entries and shows each one's content — the raw memory and its session summary — but never writes to the database. Rows carry a Read-Only badge and have no Save or Delete. +Codex also distills memories into a local database. Runway lists those entries and shows each one's content (the raw memory and its session summary) but never writes to the database. Rows carry a Read-Only badge and have no Save or Delete. -## Heads-up: agents write these files too +## Agents write these files too -A running agent session can rewrite memory files at any moment. The changed-on-disk protection above is best-effort — it catches changes on save and window focus, not the instant they happen. When an agent is actively working in a project, prefer reading over editing until it's done. +A running agent session can rewrite memory files at any moment. The changed-on-disk check is best-effort: it runs on save and window focus, not the instant a change happens. When an agent is actively working in a project, prefer reading over editing until it is done. diff --git a/docs/menu-bar.md b/docs/menu-bar.md index 99e76a331..88e0a017d 100644 --- a/docs/menu-bar.md +++ b/docs/menu-bar.md @@ -1,34 +1,31 @@ # Menu Bar -Star your most important metrics straight into the menu bar strip. +Star your most important metrics into the menu bar strip. ## Right-clicking the icon -Right-click (or control-click) the menu bar icon for a quick menu with **Settings**, **Memory** (opens the [Memory Explorer](memory-explorer.md)), and **Quit**. Left-click opens the popover as usual. +Right-click (or control-click) the menu bar icon for a menu with **Settings**, **Memory** (opens the [Memory Explorer](memory-explorer.md)), and **Quit**. Left-click opens the popover. ## Starring -Star a metric from any row's right-click menu, or from the always-visible star beside a metric in Customize. +Star a metric from any row's right-click menu, or from the star beside a metric in Customize. -- On first launch the app ships with a default set of stars (Antigravity Session/Weekly, Claude Session/Weekly, Codex Weekly, Cursor Models/Other Models, Copilot Credits, OpenRouter Credits, Z.ai Session/Weekly) so the strip shows numbers right away. Each discovered Claude or Codex account card gets its family's same default stars, with values from that account; a secondary card discovered later receives them once and then keeps your changes. Change them anytime; a provider's Reset restores its defaults, and Reset All restores the full set. Only providers that are turned on render in the strip. A fresh install starts with just the providers detected on your Mac (see [Dashboard § First launch](dashboard.md#first-launch)). So the default stars don't crowd the menu bar with tools you don't use. -- At most **2 applicable stars per provider**. If an account or plan change makes a starred metric - unavailable, Runway keeps that preference for a future switch back, but the dormant star does not - consume one of the current account's two slots. If a later switch makes more than two saved stars - live at once, the strip renders the first two in Customize order without deleting the saved choices. -- When a star isn't allowed, the star button stays clickable — clicking it shakes and shows the reason in a temporary pill over the bottom of Customize (for example, "Up to 2 stars per provider"). +- The app ships with a default set of stars (Antigravity Session and Weekly, Claude Session and Weekly, Codex Weekly, Cursor Models and Other Models, Copilot Credits, Muse Five-Hour Usage and Weekly Usage, OpenRouter Credits, Z.ai Session and Weekly) so the strip shows numbers right away. Each discovered Claude or Codex account card gets its family's default stars with its own values. A card discovered later receives them once and then keeps your changes. A provider's Reset restores its defaults, and Reset All restores the full set. Only providers that are on render in the strip, and a fresh install starts with just the providers detected on your Mac (see [Dashboard § First launch](dashboard.md#first-launch)). +- At most **2 stars per provider**. If an account or plan change makes a starred metric unavailable, Runway keeps the star for a future switch back, and the dormant star does not use one of the two slots. If a later switch makes more than two saved stars live at once, the strip renders the first two in Customize order without deleting the others. +- When a star is not allowed, the star button stays clickable. Clicking it shakes and shows the reason in a temporary pill at the bottom of Customize (for example, "Up to 2 stars per provider"). ## Styles Settings → Appearance → Icon Style: -- **Text** — provider icon plus values; two starred metrics from the same provider stack as a labeled pair. Hover an account segment to see that card's current name. -- **Bars** — a compact glyph containing the first four starred metrics that have a limit (metrics without limits only appear in Text style). +- **Text**: provider icon plus values. Two starred metrics from the same provider stack as a labeled pair. Hover an account segment to see that card's current name. +- **Bars**: a compact glyph with the first four starred metrics that have a limit. Metrics without limits only appear in Text style. ## Hiding usage while screen sharing -Settings → General → Privacy → **Hide From Screen Share** (off by default). While your screen is shared or recorded — a Zoom/Meet/Teams share, a screen recording, macOS Screen Sharing — the strip shows the Runway icon and wordmark instead. Token counts and spend never show up in front of an audience. The moment the capture ends, your starred metrics come right back. Captures you start yourself (a screen recording, for example) count too, so those get the wordmark as well. +Settings → General → Privacy → **Hide From Screen Share** (off by default). While your screen is shared or recorded (a Zoom, Meet, or Teams share, a screen recording, macOS Screen Sharing), the strip shows the Runway icon and wordmark instead of your numbers. When the capture ends, your starred metrics come back. Captures you start yourself count too. -Detection uses the system's own "an app is capturing the screen" signal — the same one that lights the capture indicator in the menu bar. Runway checks it the instant it changes, and again every few seconds while the setting is on. +Detection uses the system's own screen-capture signal, the one that lights the capture indicator in the menu bar. Runway checks it the instant it changes, and again every few seconds while the setting is on. Normally: @@ -40,16 +37,16 @@ While the screen is shared or recorded: ## The notch -On MacBooks with a notch, a crowded menu bar can push the Runway item underneath it — the item still exists, but you can't see or click it. Runway watches for this and recovers on its own: +On MacBooks with a notch, a crowded menu bar can push the Runway item under the notch where you cannot see or click it. Runway watches for this and recovers: -1. **Move back into view** — the item's remembered menu-bar position is rewritten so it re-lands just right of the notch. This is the normal outcome; the strip can hop visibly. -2. **Surrogate button** — if the move doesn't stick, a small round Runway button appears just below the menu bar next to the notch. Click it to open the dashboard, drag it anywhere, or right-click → Hide Until Relaunch. -3. Either way, the dashboard always opens beside the notch where you can see it, never centered under it. +1. **Move back into view.** The item's remembered position is rewritten so it lands just right of the notch. This is the normal outcome. The strip can hop visibly. +2. **Surrogate button.** If the move does not stick, a small round Runway button appears just below the menu bar next to the notch. Click it to open the dashboard, drag it anywhere, or right-click for Hide Until Relaunch. +3. Either way, the dashboard opens beside the notch, never centered under it. -Free up menu bar space (quit other menu bar apps, or use a menu bar manager) and everything returns to normal automatically. +Free up menu bar space (quit other menu bar apps, or use a menu bar manager) and everything returns to normal. -On macOS 27 and later this recovery is disabled: the system manages menu-bar overflow natively, folding items that don't fit behind a chevron next to the notch instead of hiding them. +On macOS 27 and later this recovery is off. The system folds items that do not fit behind a chevron next to the notch. ## What the strip shows -The strip only renders real data. A starred metric with nothing fetched yet is skipped; a provider whose stars all lack data disappears entirely (icon included). When nothing has data, the strip falls back to the app icon. Stars follow your Customize order — Always Visible metrics first, then On Demand ones. A metric can be starred whether it's Always Visible or On Demand. +The strip only renders real data. A starred metric with nothing fetched yet is skipped. A provider whose stars all lack data disappears, icon included. When nothing has data, the strip shows the app icon. Stars follow your Customize order: Always Visible metrics first, then On Demand ones. A metric can be starred whether it is Always Visible or On Demand. diff --git a/docs/performance.md b/docs/performance.md index 25a66687d..bbeeb9dab 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,89 +1,58 @@ # Performance -Runway forked from [OpenUsage](https://github.com/robinebers/openusage) and then rebuilt its hot -paths: incremental JSONL parsing, off-main launch discovery, coalesced refresh batches, a settled -popover render path, and launch pre-warming. This page records what those changes are worth, -measured head-to-head against the fork point. +Runway forked from [OpenUsage](https://github.com/robinebers/openusage) and rebuilt its hot paths: incremental JSONL parsing, off-main launch discovery, coalesced refresh batches, a settled popover render path, and launch pre-warming. This page records what those changes are worth, measured head-to-head against the fork point. ## Results -Steady state (caches warm — normal daily use), measured 2026-08-02: +Steady state (caches warm, normal daily use), measured 2026-08-02: | Metric | OpenUsage (fork tip) | Runway | Ratio | |---|---|---|---| -| Launch → menu bar icon visible | 5.4 s | 0.29 s | 18× faster | -| CPU spent in the first 30 s after launch | 21.3 s | 3.8 s | 5.6× less | -| Open the popup (warm) → first frame | 61 ms | 23 ms | 2.7× faster | -| First popup open after launch → first frame | 2.59 s | 44 ms | 59× faster | +| Launch to menu bar icon visible | 5.4 s | 0.29 s | 18× faster | +| CPU in the first 30 s after launch | 21.3 s | 3.8 s | 5.6× less | +| Open the popover (warm) to first frame | 61 ms | 23 ms | 2.7× faster | +| First popover open after launch to first frame | 2.59 s | 44 ms | 59× faster | | One refresh pass (all providers, wall time) | 8.6 s | 2.3 s | 3.7× faster | | Main-thread stall time, 10 screen switches | 2.0 s | 0.7 s | 2.9× less | | Main-thread stall time, 10 card expands | 6.6 s | 4.1 s | 1.6× less | | Memory, steady state | 1.09 GB | 238 MB | 4.6× less | | Memory, peak during the run | 2.6 GB | 322 MB | 8× less | -First launch on this machine's corpus (caches cold — what a new install pays once): +First launch on this machine's corpus (caches cold, what a new install pays once): | Metric | OpenUsage (fork tip) | Runway | Ratio | |---|---|---|---| -| CPU spent in the first 30 s | 165 s | 12.7 s | 13× less | +| CPU in the first 30 s | 165 s | 12.7 s | 13× less | | Peak memory during the initial scan | 12.9 GB | 1.7 GB | 7.5× less | -## Methodology - -Both apps ran the same scripted workload on the same machine (Apple Silicon MacBook Pro, -macOS 26.5), against the same local data, minutes apart: - -- **Same code lineage, fixed points.** Runway at `f752361` (the `main` tip after PRs #54–#59, - 2026-08-02) vs OpenUsage at its `main` tip (`9d2bf09`, 2026-07-19 — also the fork point), built - with the same Swift toolchain in release configuration, with the same measurement harness - compiled into both. -- **Matched content.** Both apps were configured with the identical provider set (claude, codex, - copilot, grok, sakana plus the same two account cards), reading the same credentials and the same - local session-log corpus (~27 GB of Codex JSONL, ~400 MB of Claude logs). -- **Same workload.** The in-app driver (`RUNWAY_UI_PROFILE=1`, see `docs/debugging.md`) ran the - identical phases in both apps: a cold popup open, 12 warm open/close cycles, 10 screen switches, - 10 card expand/collapse toggles, and an idle soak, with an 8 ms main-thread stall watchdog - running throughout. Launch and refresh figures come from process accounting (`ps`) and each - app's own batch logs. -- **Steady state vs first launch.** Each app ran twice; the first run populated its log-scan - caches (reported as "first launch"), the second is the steady-state table. - -Caveats, so the numbers stay honest: - -- Figures are machine- and corpus-specific; ratios travel better than absolute numbers. -- The refresh row excludes the forced-refresh phase for both apps. The dev build's keychain ACL - turns a forced Claude refresh into an interactive prompt that no headless run can answer. - Runway's refresh deadline cut it off exactly as designed; the scheduled batches above are the - non-interactive path both apps take in normal use. -- Stall-time totals for the expand phase vary meaningfully between runs on both apps; the table - reports the same single steady-state run for each side, and Runway was lower in every paired run. +## Method + +Both apps ran the same scripted workload on the same machine (Apple Silicon MacBook Pro, macOS 26.5), against the same local data, minutes apart: + +- **Fixed points.** Runway at `f752361` (the `main` tip after PRs #54 to #59, 2026-08-02) vs OpenUsage at its `main` tip (`9d2bf09`, 2026-07-19, also the fork point), built with the same Swift toolchain in release configuration, with the same measurement harness compiled into both. +- **Matched content.** Both apps had the same provider set (claude, codex, copilot, grok, sakana plus the same two account cards), the same credentials, and the same session-log corpus (about 27 GB of Codex JSONL and 400 MB of Claude logs). +- **Same workload.** The in-app driver (`RUNWAY_UI_PROFILE=1`, see `docs/debugging.md`) ran the same phases in both apps: a cold popover open, 12 warm open/close cycles, 10 screen switches, 10 card expand/collapse toggles, and an idle soak, with an 8 ms main-thread stall watchdog. Launch and refresh figures come from process accounting (`ps`) and each app's own batch logs. +- **Steady state vs first launch.** Each app ran twice. The first run populated its log-scan caches ("first launch"). The second is the steady-state table. + +Caveats: + +- Figures are machine- and corpus-specific. Ratios travel better than absolute numbers. +- The refresh row excludes the forced-refresh phase for both apps. The dev build's keychain ACL turns a forced Claude refresh into an interactive prompt that a headless run cannot answer. Runway's refresh deadline cut it off as designed. The scheduled batches above are the non-interactive path both apps take in normal use. +- Stall-time totals for the expand phase vary between runs on both apps. The table reports the same single steady-state run for each side. Runway was lower in every paired run. ## Reproducing -The UI rows (popup opens, stall totals) come straight from the built-in harness: +The UI rows (popover opens, stall totals) come from the built-in harness: ``` script/profile_ui.sh ``` -builds, drives the scripted phases, and prints the per-phase stats. See the "Profile the UI" -section of [docs/debugging.md](debugging.md) for what each number means and for the -`RUNWAY_UI_PROFILE_COLD=1` true-cold variant. - -The cross-app rows need the two-build procedure the tables came from — the harness alone measures -only the current Runway tree: - -1. Check out OpenUsage at `9d2bf09` in a separate worktree, compile the same `UIProfiler` + - `StatusItemController` instrumentation into it, and stage both dev bundles. -2. Match both apps' enabled-provider defaults (`runway.enabledProviders.v1` / - `openusage.enabledProviders.v1`) to the identical list. -3. Launch each with `RUNWAY_UI_PROFILE=1` and sample around the run: launch latency is the delta - from exec to the "Status item ready" log line, CPU/RSS via `ps` at +30 s / end of run / after a - 60 s idle window (with a poller recording peak RSS), and refresh wall time from each app's own - "batch end" log lines. -4. Run each app twice: the first run measures the cold caches ("first launch" table), the second - the steady state. For the first run to actually be cold, delete each app's persisted scan cache - beforehand: `~/Library/Application Support/Runway/log-scan-cache/` (upstream: - `OpenUsage/log-scan-cache/`). Leave the matched provider/account defaults in place. A revision - that has ever been profiled on the machine otherwise starts warm and silently reports another - steady-state run. +It builds, drives the scripted phases, and prints the per-phase stats. See "Profile the UI" in [docs/debugging.md](debugging.md) for what each number means and the `RUNWAY_UI_PROFILE_COLD=1` true-cold variant. + +The cross-app rows need the two-build procedure the tables came from. The harness alone measures only the current Runway tree: + +1. Check out OpenUsage at `9d2bf09` in a separate worktree, compile the same `UIProfiler` and `StatusItemController` instrumentation into it, and stage both dev bundles. +2. Match both apps' enabled-provider defaults (`runway.enabledProviders.v1` / `openusage.enabledProviders.v1`) to the same list. +3. Launch each with `RUNWAY_UI_PROFILE=1` and sample around the run: launch latency is the delta from exec to the "Status item ready" log line, CPU and RSS via `ps` at +30 s, end of run, and after a 60 s idle window (with a poller recording peak RSS), and refresh wall time from each app's "batch end" log lines. +4. Run each app twice: the first run measures the cold caches ("first launch"), the second the steady state. For the first run to be cold, delete each app's persisted scan cache first: `~/Library/Application Support/Runway/log-scan-cache/` (upstream: `OpenUsage/log-scan-cache/`). Leave the matched provider and account defaults in place. A revision that has ever been profiled on the machine otherwise starts warm and reports another steady-state run. diff --git a/docs/pricing.md b/docs/pricing.md index 0f4b2c723..aed1850f5 100644 --- a/docs/pricing.md +++ b/docs/pricing.md @@ -1,56 +1,38 @@ # Model Pricing -How Runway turns token counts into the estimated dollars on the spend tiles (Claude, Codex, -Cursor, Grok, and fixed-rate Sakana Fugu models). OpenRouter and OpenCode are the exceptions: -OpenRouter's API reports billed dollars directly, and OpenCode records its own per-message cost in its -local logs, so nothing here applies to them. +How Runway turns token counts into the estimated dollars on the spend tiles (Claude, Codex, Cursor, Grok, and fixed-rate Sakana Fugu models). OpenRouter and OpenCode are different: OpenRouter's API reports billed dollars directly, and OpenCode records its own per-message cost in its local logs. ## Where prices come from Runway layers prices from three sources. When the same model appears in more than one, the higher layer wins: -1. **Runway pricing supplement** — a small JSON file maintained in this repo and published to GitHub Pages. It covers models no public catalog carries (Cursor-native models like `auto` and `composer-*`), fast-variant multipliers, and alias rules that map provider log/CSV slugs to catalog keys. -2. **LiteLLM** — the community-maintained `model_prices_and_context_window.json`, covering the vast majority of API-priced models. -3. **models.dev** — a gap-filler for models LiteLLM misses (e.g. some brand-new or niche models). +1. **Runway pricing supplement**: a small JSON file in this repo, published to GitHub Pages. It covers models no public catalog has (Cursor-native models like `auto` and `composer-*`), fast-variant multipliers, and alias rules that map provider log and CSV slugs to catalog keys. +2. **LiteLLM**: the community-maintained `model_prices_and_context_window.json`, which covers most API-priced models. +3. **models.dev**: a gap-filler for models LiteLLM misses. -The app ships with bundled snapshots of all three, so pricing works offline and on first launch. At runtime the app refetches each source about once an hour (with ETag revalidation) and caches it in `~/Library/Application Support/Runway/pricing/`. A refresh never blocks a usage scan — scans always price against the freshest data already on hand. +The app ships with bundled snapshots of all three, so pricing works offline and on first launch. At runtime it refetches each source about once an hour (with ETag revalidation) and caches it in `~/Library/Application Support/Runway/pricing/`. A refresh never blocks a usage scan. Scans price against the freshest data already on hand. -Because the supplement is published to GitHub Pages on merge, a pricing correction reaches installed apps within about an hour — no app update needed. +Because the supplement is published to GitHub Pages on merge, a pricing correction reaches installed apps within about an hour with no app update. -Updating the app also works. The supplement carries an `updated_at` date, and the app uses whichever of the cached and bundled copies is newer, so a build shipping fresher rates applies them straight away instead of waiting on the cache to expire. That matters most offline: without it, an old cache would shadow the shipped rates for as long as the feed stayed unreachable. When the two dates are equal the cache keeps winning, so a second supplement revision on the same day must use a full ISO timestamp (`2026-08-13T14:30:00Z`) as its `updated_at` — timestamps sort after the bare date, so the later revision wins. +Updating the app also works. The supplement carries an `updated_at` date, and the app uses whichever of the cached and bundled copies is newer, so a build with fresher rates applies them right away. When the two dates are equal the cache wins, so a second supplement revision on the same day must use a full ISO timestamp (`2026-08-13T14:30:00Z`) as its `updated_at`. Timestamps sort after the bare date, so the later revision wins. -Sakana Fugu is a narrow provider-specific exception to the layered catalogs. Runway carries -Sakana's published fixed Ultra and Cyber rates beside its log scanner because those prices include a -provider-specific 272K-token tier and are not general model-catalog entries. Plain `fugu` remains -unpriced because its rate depends on the underlying routed model. +Sakana Fugu is a provider-specific exception. Runway carries Sakana's published fixed Ultra and Cyber rates beside its log scanner, because those prices include a 272K-token tier and are not general catalog entries. Plain `fugu` stays unpriced because its rate depends on the underlying routed model. ## How a model name resolves -Log and CSV model names rarely match a catalog key exactly. Resolution tries these steps in order: supplement alias rules, exact key match, fast-variant handling (a `-fast` suffix resolves the base model and applies its fast multiplier), then fuzzy matching. Fuzzy matching covers provider prefixes (`anthropic/`, `xai/`, …), dated suffixes (`claude-sonnet-4` ↔ `claude-sonnet-4-20250514`), and separator differences (`grok-4-3` ↔ `grok-4.3`). Fast variants without an explicit price or model-specific multiplier stay unpriced instead of silently using the standard-speed rate. Some providers flag fast mode on the request instead of the model name (Claude logs carry a `speed` field); those requests keep the base model name and bill at the base entry's fast multiplier — whether it comes from a catalog or from the supplement's `fast_multipliers`. +Log and CSV model names rarely match a catalog key exactly. Resolution tries these steps in order: supplement alias rules, exact key match, fast-variant handling (a `-fast` suffix resolves the base model and applies its fast multiplier), then fuzzy matching. Fuzzy matching covers provider prefixes (`anthropic/`, `xai/`), dated suffixes (`claude-sonnet-4` ↔ `claude-sonnet-4-20250514`), and separator differences (`grok-4-3` ↔ `grok-4.3`). Fast variants without an explicit price or model-specific multiplier stay unpriced instead of silently using the standard rate. Some providers flag fast mode on the request instead of the model name (Claude logs carry a `speed` field). Those requests keep the base model name and bill at the base entry's fast multiplier, from a catalog or from the supplement's `fast_multipliers`. -When no source can price a model, Runway leaves it out of the spend figures entirely. Its tokens do not count toward the day's tile, the Usage Trend, or the model breakdown — a token count next to a dollar figure that ignores part of it is misleading. Instead, a warning triangle on the affected tiles lists the unpriced models, so you know the figures are incomplete and which model is responsible. A day where *nothing* could be priced reads "No data". +When no source can price a model, Runway leaves it out of the spend figures. Its tokens do not count toward the day's tile, the Usage Trend, or the model breakdown, because a token count next to a dollar figure that ignores part of it would mislead. A warning triangle on the affected tiles lists the unpriced models. A day where nothing could be priced reads "No data". ## What the estimate includes -Runway computes costs per usage event from token buckets at the model's per-million-token rates. -This includes cache pricing, long-context tiers, and fast-variant multipliers. Most catalog tiers start -above 200k prompt tokens; supported GPT-5.4, GPT-5.5, and GPT-5.6 Codex models switch above 272k input -tokens. Fugu Ultra and Cyber also switch the whole request above 272k, using Sakana's published input, -cached-input, and output rates. Codex rollouts do not preserve Sakana's separate orchestration-detail -fields, so Fugu estimates and graph tokens can undercount orchestration; reasoning output is already -part of output and is not added again. Runway uses a published cache discount when available; Codex cached -input falls back to the full input rate when the source publishes no discount. Cursor's export combines -many requests into each row, so Runway uses the normal rate there rather than guessing that one -request crossed the limit. When a Claude log line carries an explicit `costUSD`, Runway uses that value -as-is. Nested Claude advisor usage has no carried cost, so Runway prices it separately from its -tokens using the advisor model. The result is an estimate of API-rate value, not a bill: subscription plans -don't charge per token. +Runway computes costs per usage event from token buckets at the model's per-million-token rates. This includes cache pricing, long-context tiers, and fast-variant multipliers. Most catalog tiers start above 200k prompt tokens. Supported GPT-5.4, GPT-5.5, and GPT-5.6 Codex models switch above 272k input tokens. Fugu Ultra and Cyber also switch the whole request above 272k, using Sakana's published input, cached-input, and output rates. Codex rollouts do not preserve Sakana's separate orchestration-detail fields, so Fugu estimates and graph tokens can undercount orchestration. Reasoning output is already part of output and is not added again. Runway uses a published cache discount when available. Codex cached input falls back to the full input rate when the source publishes no discount. Cursor's export combines many requests into each row, so Runway uses the normal rate there rather than guessing that one request crossed the limit. When a Claude log line carries an explicit `costUSD`, Runway uses that value as-is. Nested Claude advisor usage has no carried cost, so Runway prices it separately using the advisor model. The result is an estimate of API-rate value, not a bill. Subscription plans do not charge per token. ## Privacy -The pricing refresh fetches three public price lists (from `raw.githubusercontent.com`, `models.dev`, and this repo's GitHub Pages). These requests carry no usage or log data — nothing about your usage leaves your Mac. +The pricing refresh fetches three public price lists (from `raw.githubusercontent.com`, `models.dev`, and this repo's GitHub Pages). These requests carry no usage or log data. ## Maintainer notes -- **Supplement changes** (new Cursor-native model, price correction, new alias): edit `Sources/Runway/Resources/pricing_supplement.json`, sync entries from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md), and update `updated_at`. On merge to `main`, `.github/workflows/pricing-supplement.yml` publishes it to `update-feed`; installed apps pick it up within about an hour. The bundled copy ships with the next release for first launches. The **pricing-update skill** (`.agents/skills/pricing-update/`) walks an agent through the whole sync: pull the Cursor page, diff, edit, validate, and open a PR. -- **Bundled snapshots** (`pricing_litellm_snapshot.json`, `pricing_models_dev_snapshot.json`): regenerate occasionally (e.g. before a release) with `script/update_pricing_snapshots.sh`. Staleness is harmless — runtime fetches override them. +- **Supplement changes** (new Cursor-native model, price correction, new alias): edit `Sources/Runway/Resources/pricing_supplement.json`, sync entries from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md), and update `updated_at`. On merge to `main`, `.github/workflows/pricing-supplement.yml` publishes it to `update-feed`. Installed apps pick it up within about an hour. The bundled copy ships with the next release for first launches. The pricing-update skill (`.agents/skills/pricing-update/`) walks an agent through the sync: pull the Cursor page, diff, edit, validate, and open a PR. +- **Bundled snapshots** (`pricing_litellm_snapshot.json`, `pricing_models_dev_snapshot.json`): regenerate before a release with `script/update_pricing_snapshots.sh`. Staleness is harmless because runtime fetches override them. diff --git a/docs/privacy.md b/docs/privacy.md index 906cae22d..10e2fa6e4 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,33 +1,38 @@ # Privacy -Runway does not collect product analytics or usage statistics. It includes no analytics or crash-reporting service, creates no analytics identifier, and sends no app-use events, provider-refresh summaries, error categories, or crash reports. +Runway collects no product analytics or usage statistics. It has no analytics or crash-reporting service, creates no analytics identifier, and sends no app-use events, refresh summaries, error categories, or crash reports. On the first launch after upgrading from a version that included analytics, Runway deletes the retired analytics identifier and counters that version stored locally. -Provider usage stays on your Mac except for the network requests needed to read each provider's limits and the optional services you explicitly enable. +Provider usage stays on your Mac except for the network requests needed to read each provider's limits, and the optional services described below, which you can turn off. -## Credentials Stored on This Mac +## Credentials stored on this Mac -Runway reads credentials that provider tools already keep on your Mac. Claude, Codex, Cursor, Copilot, and Muse logins are strictly read-only — Runway never refreshes them and never writes their Keychain items, databases, or credential files; each tool owns its own login and token rotation. Grok and Kimi are the exception: Runway refreshes those tokens and saves them back to the same credential files their CLIs use, replacing each file atomically and restricting it to your macOS account (owner read and write only) — the same handling as a user-supplied API key. Antigravity's access token is refreshed through Google OAuth (Google refresh tokens do not rotate) and cached in Runway's own file, never written back to Antigravity's Keychain item. Antigravity's short-lived refreshed-token cache is tied to the current Keychain login using a one-way fingerprint; the refresh credential itself is not copied. The cache is never used after logout, an account change, or while Keychain access is unavailable. Muse Code's usage endpoint can include an API key snapshot; Runway reads the subscription meters from that response and does not save the key. +Runway reads credentials that provider tools already keep on your Mac. -All Claude access is strictly read-only. Runway never refreshes a Claude OAuth token and never writes to Claude Code's Keychain items or `.credentials.json` — Claude owns its logins and their rotation. For Claude Desktop, Runway can ask macOS for permission to use the `Claude Safe Storage` Keychain item so it can decrypt Desktop's current access token; it never uses Desktop's rotating refresh token and never modifies Desktop's config, cookies, or Keychain data. +- **Codex, Cursor, Copilot, Muse:** read-only. Runway never refreshes these logins and never writes their Keychain items, databases, or credential files. Each tool owns its own login and token rotation. +- **Claude Code:** Runway never writes Claude Code's Keychain item or `.credentials.json` except in one guarded case. When a stored token has already been expired for a while, so no live Claude Code session can be mid-rotation, Runway renews it and writes the rotated credential back to the same store, keeping one token chain. See [Claude](providers/claude.md) for the rules and the switch that turns this off. +- **Claude Desktop:** read-only. Runway can ask macOS for permission to use the `Claude Safe Storage` Keychain item so it can decrypt Desktop's current access token. It never uses Desktop's refresh token and never modifies Desktop's config, cookies, or Keychain data. +- **Grok and Kimi:** Runway refreshes these tokens and saves them back to the same credential files their CLIs use, replacing each file atomically and restricting it to your macOS account (owner read and write only). +- **Antigravity:** the access token is refreshed through Google OAuth (Google refresh tokens do not rotate) and cached in Runway's own file, never written back to Antigravity's Keychain item. The cache is tied to the current Keychain login by a one-way fingerprint and is never used after logout, an account change, or while Keychain access is unavailable. +- **Muse Code:** the usage endpoint can include an API key. Runway reads the subscription meters from that response and does not save the key. -## Other Network Requests +## Other network requests -Besides the provider API calls that the vendor's own tools also make, Runway fetches public [model price lists](pricing.md) about once an hour from `raw.githubusercontent.com`, `models.dev`, and this project's GitHub Pages. These are plain downloads of public data and carry no usage, log, or account information. Spend tiles are computed from local CLI logs entirely on your Mac; no log data leaves it. +Besides the provider API calls, Runway fetches public [model price lists](pricing.md) about once an hour from `raw.githubusercontent.com`, `models.dev`, and this project's GitHub Pages. These are plain downloads of public data and carry no usage, log, or account information. Spend tiles are computed from local CLI logs on your Mac. No log data leaves it. -Runway also checks its signed update feed in release builds. See [Updates](updates.md). +Release builds also check the signed update feed. See [Updates](updates.md). -## Local Usage Cache +## Local usage cache -To avoid re-reading unchanged Claude, Codex, and pi logs after every relaunch, Runway keeps parsed usage events in `~/Library/Application Support/Runway/log-scan-cache/`. These records contain the usage metadata needed for local totals, including any per-event cost already recorded by a provider, but not raw JSONL lines or conversation text. +To avoid re-reading unchanged Claude, Codex, and pi logs after every relaunch, Runway keeps parsed usage events in `~/Library/Application Support/Runway/log-scan-cache/`. These records hold the usage metadata needed for local totals, including any per-event cost a provider recorded, but not raw JSONL lines or conversation text. -The cache is private to your macOS account and is never sent to a provider or iCloud. Runway drops old source-file records as the scan window advances, and removes identity caches that have not been used for 35 days. Runway's pricing engine runs after the cache is read, so its computed aggregates and totals are not persisted in this cache. +The cache is private to your macOS account and is never sent to a provider or iCloud. Runway drops old source-file records as the scan window advances and removes identity caches unused for 35 days. Pricing runs after the cache is read, so computed totals are not stored in it. ## iCloud Sync -iCloud Sync is on by default; you can turn it off in Settings. With [iCloud Sync](icloud-sync.md) on, Runway writes normalized daily tokens, spend, and model totals to its private CloudKit database, plus each device's latest rendered usage snapshot (current quotas, plans, balances, and refresh errors). Your own devices use this data to show one combined summary and live usage. All of it stays inside your iCloud account and is never visible to Runway's developers or any provider. Credentials, raw provider responses, and raw logs are never written there. Turning sync off deletes this device's record from iCloud. +iCloud Sync is on by default. You can turn it off in Settings. With [iCloud Sync](icloud-sync.md) on, Runway writes normalized daily tokens, spend, and model totals to its private CloudKit database, plus each device's latest rendered usage snapshot (current quotas, plans, balances, and refresh errors). Your own devices use this data to show one combined summary and live usage. It stays inside your iCloud account and is never visible to Runway's developers or any provider. Credentials, raw provider responses, and raw logs are never written there. Turning sync off deletes this device's record from iCloud. -## Local Diagnostics +## Local diagnostics -Runway writes a redacted diagnostic log on your Mac so failures remain visible and debuggable. The log is not uploaded automatically. See [Logging](logging.md). +Runway writes a redacted diagnostic log on your Mac. It is never uploaded automatically. See [Logging](logging.md). diff --git a/docs/provider-enablement.md b/docs/provider-enablement.md index c5e28053b..873d07c77 100644 --- a/docs/provider-enablement.md +++ b/docs/provider-enablement.md @@ -1,32 +1,32 @@ # Which Providers Are On -How Runway decides which providers start on, what happens when an update adds a new provider, and the one rule that governs it all: **your own toggles always win and are never overridden.** +How Runway decides which providers start on and what happens when an update adds a new provider. The one rule: your own toggles always win and are never overridden. ## First install -A fresh install doesn't turn on every provider Runway knows about. It starts with Claude, Codex, and Cursor. It then quickly checks which providers have credentials on your Mac — an existing local login, a saved API key, or a supported environment variable. The check is local; nothing leaves your Mac. The app then switches to exactly the set it found. If it finds nothing, the Claude/Codex/Cursor starter set stays. If the app closes before this setup starts, it resumes on the next launch. The app checks all providers at once, so detection takes as long as the slowest single check, not the sum of them. When the check turns a provider on, Runway fetches it right away, so it shows data at once instead of at the next scheduled refresh. See [Dashboard § First launch](dashboard.md#first-launch) for how the dashboard presents this. +A fresh install starts with Claude, Codex, and Cursor. It then checks which providers have credentials on your Mac: an existing local login, a saved API key, or a supported environment variable. The check is local. Nothing leaves your Mac. The app then switches to exactly the set it found. If it finds nothing, the Claude, Codex, and Cursor starter set stays. If the app closes before this setup starts, it resumes on the next launch. All providers are checked at once, so detection takes as long as the slowest single check. When the check turns a provider on, Runway fetches it right away. See [Dashboard § First launch](dashboard.md#first-launch) for how the dashboard presents this. ## When an update adds a new provider -The same detection runs for providers that arrive later. On the first launch after an update, Runway compares the providers it now ships with the ones this install has seen before. For each brand-new one, it runs the same local-only credential check: +On the first launch after an update, Runway compares the providers it now ships with the ones this install has seen before. For each new one, it runs the same local credential check: -- **Credentials are available locally** → the provider turns on and appears on the dashboard. -- **No credentials are available** → it stays off. You can always turn it on later in **Customize**. +- **Credentials found**: the provider turns on and appears on the dashboard. +- **No credentials**: it stays off. You can turn it on later in **Customize**. -This check happens **once per provider**. After that, the provider is yours to manage. If you turn it off, no update will ever turn it back on. If you install the tool later, that won't flip it on behind your back either — head to Customize when you want it. +This check happens once per provider. After that, the provider is yours to manage. If you turn it off, no update turns it back on. If you install the tool later, that does not flip it on either. Use Customize when you want it. -## Your choices always stick +## Your choices stick -Everything you set in Customize — providers on or off, metric layout, menu-bar stars — carries across updates untouched. The only change an update can ever make is to turn **on** a provider you have never seen before, and only when you actually have that tool installed. +Everything you set in Customize (providers on or off, metric layout, menu-bar stars) carries across updates. The only change an update can make is to turn on a provider you have never seen before, and only when you have that tool installed. -The one exception is deliberate: the **Reset All Customization** button at the top of the Customize provider list. Because you asked for a clean slate, it re-runs the same local credential detection as first launch. It then switches the enabled set back to exactly the providers with credentials on your Mac (Claude/Codex/Cursor if it finds none). So it can turn a provider off even if you had it on, or back on if you had turned it off. It also asks for confirmation first. See [Dashboard](dashboard.md) for the metric side of that reset. +The one exception is **Reset All Customization** at the top of the Customize provider list. It re-runs the same credential detection as first launch and switches the enabled set back to exactly the providers with credentials on your Mac (Claude, Codex, and Cursor if it finds none). So it can turn a provider off even if you had it on, or on if you had turned it off. It asks for confirmation first. See [Dashboard](dashboard.md) for the metric side of that reset. -## How it works (for the curious) +## How it works The app persists three small lists in its settings: -- **Enabled providers** — the providers currently on. This is the source of truth the dashboard and menu bar read. -- **Known providers** — every provider this install has ever seen. This is what makes "new in this update" distinguishable from "you turned it off": a provider missing from the enabled list but present in the known list is a deliberate choice, and the app leaves it alone. Only providers missing from *both* get the credential check, and the app marks each one known immediately so the check never repeats. -- Each provider implements a cheap, local-only credential probe (`hasLocalCredentials()`) — the same files, keychain entries, saved keys, and environment variables its normal refresh reads, never the network. +- **Enabled providers**: the providers currently on. The dashboard and menu bar read this. +- **Known providers**: every provider this install has ever seen. This is what separates "new in this update" from "you turned it off". A provider missing from the enabled list but present in the known list is your choice, and the app leaves it alone. Only providers missing from both get the credential check, and the app marks each one known right away so the check never repeats. +- Each provider implements a cheap, local-only credential probe (`hasLocalCredentials()`) that checks the same files, keychain entries, saved keys, and environment variables its normal refresh reads, never the network. -Older installs (from before first-run detection existed) started with every provider on and stored only the ones turned *off*. A one-time settings migration converts them to the lists above with the exact same providers on and off as before — nothing visibly changes on the launch that migrates; those installs simply join the same new-provider detection from then on. +Older installs (from before first-run detection existed) started with every provider on and stored only the ones turned off. A one-time settings migration converts them to the lists above with the same providers on and off as before. Nothing visibly changes on the launch that migrates. Those installs then join the same new-provider detection. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index 7eea144d5..3621b9230 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -4,44 +4,44 @@ Tracks pool quotas for Antigravity (Google's AI IDE) using credentials the app o ## What it tracks -Antigravity has two shared quota pools, and each pool has two windows — a rolling 5-hour window and a weekly window: +Antigravity has two shared quota pools, and each pool has a rolling 5-hour window and a weekly window: | Metric | Meaning | |---|---| | Session | The shared Gemini pool (Pro and Flash draw from the same quota), rolling 5-hour window | | Weekly | The same Gemini pool's weekly window | -| Claude | The shared non-Gemini pool (Claude, GPT-OSS, …), rolling 5-hour window | +| Claude | The shared non-Gemini pool (Claude, GPT-OSS, and others), rolling 5-hour window | | Claude Weekly | The same non-Gemini pool's weekly window | When Antigravity reports your subscription tier (such as `Pro` or `Ultra`), Runway shows it beside the provider name. -Gemini Pro and Gemini Flash are one pool: using either model drains the same quota, so Runway shows one meter per window instead of separate Pro and Flash meters. That pair is named Session and Weekly to match the other providers' rows. Every non-Gemini model shares the second pool, shown under the Claude name (like Codex's Spark pair). Quotas are reported as a fraction (full = 0% used), so there are no token or dollar spend tiles. +Gemini Pro and Gemini Flash are one pool, so Runway shows one meter per window instead of separate Pro and Flash meters. That pair is named Session and Weekly to match the other providers. Every non-Gemini model shares the second pool, shown under the Claude name. Quotas are reported as a fraction, so there are no token or dollar spend tiles. -When a pool's rolling 5-hour window has no usage yet, that meter reads **Not started** on the trailing label instead of a reset countdown; hover explains that the session begins after your first message. The weekly meters always show a normal reset countdown. +When a pool's 5-hour window has no usage yet, that meter reads **Not started** instead of a reset countdown. Hover it for an explanation. The weekly meters always show a reset countdown. ## Where credentials come from -Runway never asks for a token — it reads what Antigravity already has: +Runway reads what Antigravity already has: -- **Antigravity running** — Runway talks to the app's local language server (the richest source, and where the plan name comes from). -- **App closed** — it falls back to the OAuth token Antigravity / `agy` store in your macOS Keychain and queries Google's Cloud Code API. Runway refreshes an expired token automatically (it never writes back to Antigravity's own keychain item). Runway reuses its short-lived cache only while the same Keychain login is present and readable. +- **Antigravity running**: Runway talks to the app's local language server. This is the richest source and where the plan name comes from. +- **App closed**: Runway falls back to the OAuth token Antigravity and `agy` store in your macOS Keychain and queries Google's Cloud Code API. Runway refreshes an expired token itself and never writes back to Antigravity's keychain item. It reuses its short-lived cache only while the same Keychain login is present and readable. -If neither is available you'll see *Start Antigravity or run `agy` and try again.* +If neither is available you see *Start Antigravity or run `agy` and try again.* ## Troubleshooting -- **"Start Antigravity or run `agy`…"** — sign in to the Antigravity app (or run `agy`) so a usable token exists, then refresh. A manual refresh (⌘R) looks for the local server immediately; the automatic 5-minute passes check for it again within about 15 minutes of it starting. -- **"Antigravity login found in Keychain"** (a neutral key glyph, not a warning) — the item is present, but automatic refreshes do not request its secret. Connect to load it; choose **Always Allow** to avoid a dialog on future manual reads. -- **"Keychain access to the Antigravity login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. -- **"Couldn't read Antigravity credentials from Keychain"** — the keychain itself couldn't be read (usually locked). Unlock it, then refresh; there's nothing to approve in this state. -- **"Couldn't read Antigravity credentials…"** — unlock Keychain, or sign in to Antigravity again. Runway will not use its cached access token until the current login can be verified. -- **The weekly meters show "No data"** — your Antigravity build doesn't expose the quota-summary endpoint yet (only newer builds do). The 5-hour meters still work from the older endpoints; updating Antigravity brings the weekly meters back. -- **A meter shows "No data"** — that pool/window wasn't in the latest response (some tiers only report certain windows). The other meters still update. -- **Where did the Gemini Pro and Flash meters go?** — merged: both models draw from the one shared Gemini pool, which is now the single Session meter. -- **Quotas look full after heavy use** — the 5-hour windows reset on a rolling basis and the weekly windows once a week; the reset time is shown on each meter. +- **"Start Antigravity or run `agy`…"**: sign in to the Antigravity app or run `agy` so a usable token exists, then refresh. A manual refresh (⌘R) looks for the local server immediately. The automatic 5-minute passes find it within about 15 minutes of it starting. +- **"Antigravity login found in Keychain"** (neutral key glyph): the item is present, but automatic refreshes do not request its secret. Connect to load it. Choose **Always Allow** to avoid a dialog on future manual reads. +- **"Keychain access to the Antigravity login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **"Couldn't read Antigravity credentials from Keychain"**: the keychain itself could not be read, usually because it is locked. Unlock it, then refresh. +- **"Couldn't read Antigravity credentials…"**: unlock Keychain, or sign in to Antigravity again. Runway does not use its cached access token until the current login can be verified. +- **The weekly meters show "No data"**: your Antigravity build does not expose the quota-summary endpoint yet. The 5-hour meters still work from the older endpoints. Updating Antigravity brings the weekly meters back. +- **A meter shows "No data"**: that pool or window was not in the latest response. Some tiers only report certain windows. The other meters still update. +- **Where did the Gemini Pro and Flash meters go?** They merged. Both models draw from the one shared Gemini pool, which is now the Session meter. +- **Quotas look full after heavy use**: the 5-hour windows reset on a rolling basis and the weekly windows once a week. The reset time is on each meter. ## Under the hood -Best source first: the local language server discovered by scanning for the `language_server` / `agy` process and reading its CSRF token and listening ports; then Google Cloud Code using the Keychain token, refreshed via Google OAuth when needed. Runway binds its short-lived refreshed-token cache to a one-way fingerprint of the current Keychain refresh credential. Logout, account changes, legacy caches, and expired or malformed entries cannot reuse a previous account's access token. On each source Runway asks the quota-summary endpoint first (`RetrieveUserQuotaSummary` on the language server, `v1internal:retrieveUserQuotaSummary` on Cloud Code) — the only endpoint that reports the merged pools and the weekly windows. Builds without it fall back to the legacy per-model endpoints (`GetUserStatus` / `GetCommandModelConfigs` locally, `fetchAvailableModels` / `retrieveUserQuota` remotely), whose per-model quotas are merged into the two pools by keeping each pool's worst remaining fraction; those endpoints only know the 5-hour windows. The plan name prefers Antigravity's own `userTier` over the inherited Windsurf plan field. +Best source first: the local language server, found by scanning for the `language_server` / `agy` process and reading its CSRF token and listening ports; then Google Cloud Code using the Keychain token, refreshed via Google OAuth when needed. Runway binds its short-lived refreshed-token cache to a one-way fingerprint of the current Keychain refresh credential, so logout, account changes, legacy caches, and expired or malformed entries cannot reuse a previous account's access token. On each source Runway asks the quota-summary endpoint first (`RetrieveUserQuotaSummary` on the language server, `v1internal:retrieveUserQuotaSummary` on Cloud Code). That is the only endpoint that reports the merged pools and the weekly windows. Builds without it fall back to the legacy per-model endpoints (`GetUserStatus` / `GetCommandModelConfigs` locally, `fetchAvailableModels` / `retrieveUserQuota` remotely), whose per-model quotas are merged into the two pools by keeping each pool's worst remaining fraction. Those endpoints only know the 5-hour windows. The plan name prefers Antigravity's own `userTier` over the inherited Windsurf plan field. -> Reverse-engineered from the app and language-server binary; endpoints and storage can change without notice. +> Reverse-engineered from the app and language-server binary. Endpoints and storage can change without notice. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index cbe35ad7e..44ed0c94b 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -13,103 +13,69 @@ Tracks your Claude subscription limits using the login you already have from Cla | Extra Usage | Extra-usage credits spent against your monthly cap | | Today / Yesterday / Last 30 Days | Local spend, as cost, tokens, or both (see below) | -When Claude reports your plan name, Runway shows it beside the provider name. The badge follows your -current plan: Runway prefers the up-to-date plan and tier Claude Code keeps in its state file over the -copies stored at sign-in, so upgrading or downgrading shows up without signing in again. +When Claude reports your plan name, Runway shows it beside the provider name. Runway prefers the current plan and tier in Claude Code's state file over the copies stored at sign-in, so an upgrade or downgrade shows up without signing in again. ## Where credentials come from -Sign in with Claude Code or Claude Desktop; Runway reads the existing login. It checks these sources and prefers one that can read your subscription usage: +Sign in with Claude Code or Claude Desktop. Runway checks these sources and prefers one that can read your subscription usage: -1. The macOS keychain entry Claude Code maintains (its source of truth on macOS) +1. The macOS keychain entry Claude Code maintains 2. `~/.claude/.credentials.json` (or `$CLAUDE_CONFIG_DIR/.credentials.json`) 3. Claude Desktop's encrypted login cache, when no working Claude Code login is available -4. `CLAUDE_CODE_OAUTH_TOKEN` environment variable +4. The `CLAUDE_CODE_OAUTH_TOKEN` environment variable -Claude Desktop support is read-only. Runway decrypts its currently valid access token using the -`Claude Safe Storage` item in your macOS Keychain. It never reads or uses Desktop's refresh token, and -never changes Desktop's config, cookies, or Keychain entry. This prevents Runway from invalidating -Claude Desktop's session. Recent Desktop builds store tokens under account-prefixed cache keys; Runway -reads those as well as the older format, and only uses entries that belong to the signed-in Desktop account. +**Claude Desktop** support is read-only. Runway decrypts Desktop's current access token using the `Claude Safe Storage` item in your macOS Keychain. It never reads or uses Desktop's refresh token, and never changes Desktop's config, cookies, or Keychain entry, so it cannot invalidate Desktop's session. Recent Desktop builds store tokens under account-prefixed cache keys. Runway reads those as well as the older format, and only uses entries that belong to the signed-in Desktop account. -Launch-time and background refreshes never request Claude's Keychain secrets. After launch or a -credential change, the card offers a neutral **Connect** action (not a warning — nothing is broken); -that deliberate read is cached in memory for the running app session while the item's non-secret -metadata remains unchanged. Choosing **Always Allow** avoids a dialog on future -manual reads. If Desktop's short-lived token expires, open Claude Desktop so it can renew the login, -then refresh Runway. +**Keychain reads.** Launch-time and background refreshes never request Claude's Keychain secrets. After launch or a credential change, the card shows a neutral **Connect** action. That manual read is cached in memory for the running session while the item's non-secret metadata is unchanged. Choose **Always Allow** to avoid a dialog on future manual reads. If Desktop's token expires, open Claude Desktop so it can renew the login, then refresh Runway. Several Claude cards share the same cached read. Claude Desktop's `Claude Safe Storage` key is handled the same way: a manual read derives the decryption key and caches it for the rest of the process. -Runway keeps its Keychain traffic minimal. For Claude Code's credentials item it checks non-secret -attributes first. If they still match a value loaded manually in this process, automatic refreshes use -the cache; if they changed, Runway asks for another manual refresh without requesting the new secret. -Several Claude cards share the same cached read. Claude Desktop's `Claude Safe Storage` key is handled -the same way: a manual read derives the decryption key and caches it for the rest of the process. +A Claude Code Keychain item stays higher priority than a home-file or Desktop login even before access is approved. Runway reports that approval is needed instead of showing usage from a possibly stale home file or a different Desktop account. If macOS cannot tell whether the item exists (for example while the login keychain is locked), Runway asks you to unlock the keychain. -A Claude Code Keychain item remains higher priority than a home-file or Desktop login even before access -is approved. Runway reports that approval is needed instead of silently showing usage from a potentially -stale home file or a different Desktop account. If macOS cannot even determine whether the item exists -(for example, while the login keychain is locked), Runway asks you to unlock the keychain instead of -guessing or prompting in the background. +First-run detection checks Claude Desktop's files and Keychain metadata without requesting the secret. Leftover or corrupt Desktop files, and malformed Claude Code credentials found during a manual read, do not enable Claude by themselves. -First-run detection checks Claude Desktop's files and Keychain metadata without requesting the secret. -Leftover or corrupt Desktop files—and malformed Claude Code credentials found during a manual read—do -not enable Claude by themselves. +If you cancel or deny a Claude Code approval prompt during a manual refresh, Runway stops there. It does not repeat the prompt through a broader lookup or open a Claude Desktop prompt. -If you cancel or deny a Claude Code approval prompt during a manual refresh, Runway stops there. It does -not repeat the same Code prompt through a broader lookup or open an unrelated Claude Desktop prompt. +**Environment token.** A `CLAUDE_CODE_OAUTH_TOKEN` (usually a long-lived `claude setup-token`) can run the model but cannot read your Session and Weekly limits, and it often lingers in your shell. When a real keychain or file login is present, Runway uses that login for the live meters and keeps the environment token as a fallback. If the environment token is your only credential (a headless setup), it is used on its own and the spend tiles still load from local logs. -A `CLAUDE_CODE_OAUTH_TOKEN` — usually a long-lived `claude setup-token` — can run the model but can't read your Session and Weekly limits, and it often lingers in your shell environment. So when a real keychain or file login is present, Runway uses that login for the live meters and keeps the environment token only as a fallback; the Session/Weekly meters no longer go blank just because that token is set. If the environment token is your *only* credential (a headless setup), it's used on its own and the spend tiles still load from local logs. +If one source holds an expired or locked-out token, Runway falls back to the others, so signing in again with `claude` is picked up on the next refresh without restarting Runway. -If one source holds an expired or "locked out" token, Runway falls back to the others — so signing in again with `claude` outside the app is picked up on the next refresh, without restarting Runway. +**Token renewal.** Claude Code owns its login, and Runway defers to it. When a stored token has already been expired for a while, so no live Claude Code session can be mid-rotation, Runway renews it the same way Claude Code would and writes the rotated credential back to the store it came from (the Keychain item or `.credentials.json`), keeping one token chain. Two apps rotating the same login independently can trip the server's token-reuse protection and sign you out everywhere, so Runway only renews after expiry, only after verifying it can write the result back, and never for Claude Desktop's login. If renewal is not possible (a guard declines, or the refresh token itself is revoked), the live Session and Weekly meters pause and the Claude header shows **"Claude login needs renewal"**. Open Claude Code so it mints a fresh login, then refresh Runway. The local spend tiles keep working. To turn renewal off: -Claude Code owns its login, and Runway defers to it — but when a stored token has already sat expired for a while (so no live Claude Code session can be mid-rotation), Runway renews it the same way Claude Code would and writes the rotated credential back to the exact store it came from (the Keychain item or `.credentials.json`), keeping one single token chain. That discipline matters: two apps rotating the same login independently can trip the server's token-reuse protection and sign you out everywhere, so Runway only renews reactively (never before expiry), only after verifying it can write the result back, and never for Claude Desktop's login (that credential stays read-only, owned by the Desktop app). If renewal isn't possible — the guards decline, or the refresh token itself is revoked — the live Session and Weekly meters pause and the Claude header shows **"Claude login needs renewal"**: open Claude Code so it mints a fresh login, then refresh Runway. The local spend tiles keep working the whole time. To turn automatic renewal off: `defaults write com.mattstallone.runway runway.claude.disableTokenRefresh -bool true`. +```sh +defaults write com.mattstallone.runway runway.claude.disableTokenRefresh -bool true +``` ## The spend tiles -Today / Yesterday / Last 30 Days are computed **locally**: Runway reads the Claude Code session logs under `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR`) itself — no external tools needed. Symlinks are followed, so a projects folder linked into a synced location (say, a Dropbox folder) is read all the same. Claude usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too. Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Claude usage there into the same tiles and trend. pi records its own per-message cost, so those dollars come straight from pi; Runway does not re-estimate them. Cowork (the Claude desktop app's agent mode) counts too: it writes the same logs into per-session folders under `~/Library/Application Support/Claude/local-agent-mode-sessions/`, and Runway scans those as well. Desktop agent sessions show up in the tiles alongside terminal ones. Persisted `claude -p` runs count as well. Runs made with `--no-session-persistence` cannot appear because Claude deliberately writes no session log for Runway to read. Advisor work recorded inside a message is counted once under the advisor's own model; the parent's main-model totals are kept separate, and ordinary iteration details are not counted again. A log's recorded fast or standard speed controls its price; Runway does not infer speed from the event date. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); the token counts themselves are measured. No log data leaves your Mac. The spend tiles also load when there is no Claude OAuth login — for example an API-key gateway — as long as those local logs exist. The header then shows **Not logged in** because Session and Weekly cannot load. +Today, Yesterday, and Last 30 Days are computed locally. Runway reads the Claude Code session logs under `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR`). Symlinks are followed, so a projects folder linked into a synced location is read too. Claude usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too. Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Claude usage there into the same tiles and trend. pi records its own per-message cost, so those dollars come from pi and are not re-estimated. Cowork (the Claude desktop app's agent mode) writes the same logs into per-session folders under `~/Library/Application Support/Claude/local-agent-mode-sessions/`, and Runway scans those as well. Persisted `claude -p` runs count too. Runs made with `--no-session-persistence` cannot appear because Claude writes no session log for them. Advisor work recorded inside a message is counted once under the advisor's own model. The parent's main-model totals are kept separate, and ordinary iteration details are not counted again. A log's recorded fast or standard speed controls its price. Runway does not infer speed from the event date. + +Days are grouped in your Mac's local time zone. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`). A day with no usage reads **No data**. The dollars are estimated from token counts at API rates using the shared [model pricing](../pricing.md). The token counts are measured. No log data leaves your Mac. The spend tiles also load when there is no Claude OAuth login (for example an API-key gateway) as long as the local logs exist. The header then shows **Not logged in** because Session and Weekly cannot load. ## Multiple accounts -If you keep more than one Claude login on this Mac using custom config dirs (separate `CLAUDE_CONFIG_DIR` -homes, each with its own sign-in), Runway finds them at launch and gives each **account** its own -card, with its own limits, plan, and spend tiles read from that home. A custom dir signed into the same -account as your main login doesn't become a second card — its session logs simply count into the main -card's spend tiles. - -With one discovered account, the default name is simply "Claude." With multiple accounts, every card -includes its account email (for example, "Claude — dev@example.com"), including the first/default card. -An organization name appears after the email when available. If two active accounts still have the -same label, Runway adds a short stable account code. If the only login lives in a custom config dir, -it is the sole Claude card; Runway does not add an empty default-home card beside it. Right-click -a card and choose **Rename…** -(or use the Name field in Customize) to call it whatever you like. A card only shows while its login is -still found on this Mac — log it out or delete the dir and the card disappears, keeping its customization -and history for if it returns. Turn a card off like any provider in Customize. - -An ambient `CLAUDE_CODE_OAUTH_TOKEN` cannot identify its account. When a separate account card is also -discovered, default-home local spend remains available under a clearly labeled -"Claude — Environment Token" card. A leftover Claude state file—or a malformed or tokenless stored -credential beside it—does not lend an old account name to that token-only card. - -In the [CLI](../cli.md) and [local API](../local-http-api.md), extra cards appear under ids like -`claude@ab12cd34`; requesting `claude` returns every Claude card. +If you keep more than one Claude login on this Mac using custom config dirs (separate `CLAUDE_CONFIG_DIR` homes, each with its own sign-in), Runway finds them at launch and gives each account its own card, with its own limits, plan, and spend tiles. A custom dir signed into the same account as your main login does not become a second card. Its session logs count into the main card's spend tiles. + +With one account, the default name is "Claude". With multiple accounts, every card includes its account email (for example "Claude — dev@example.com"), including the first card. An organization name appears after the email when available. If two accounts still have the same label, Runway adds a short stable account code. If the only login lives in a custom config dir, it is the sole Claude card. Right-click a card and choose **Rename…** (or use the Name field in Customize) to name it yourself. A card only shows while its login is still found on this Mac. Log it out or delete the dir and the card disappears, keeping its customization and history in case it returns. Turn a card off like any provider in Customize. + +An ambient `CLAUDE_CODE_OAUTH_TOKEN` cannot identify its account. When a separate account card is also discovered, default-home local spend stays available under a "Claude — Environment Token" card. A leftover Claude state file, or a malformed or tokenless stored credential beside it, does not lend an old account name to that card. + +In the [CLI](../cli.md) and [local API](../local-http-api.md), extra cards appear under ids like `claude@ab12cd34`. Requesting `claude` returns every Claude card. ## Troubleshooting -- **"Not logged in"** — run `claude` to sign in, then refresh. If local session logs exist, the spend tiles still show; Session and Weekly stay empty until you sign in. -- **"Claude Code login found"** (a neutral key glyph / **Connect** button, not a warning) — the login exists but hasn't been loaded this app session. Connect, and choose **Always Allow** if macOS asks for access to `Claude Code-credentials`. -- **"Keychain access to the Claude Code login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. -- **"Claude Code credentials couldn't be checked"** — unlock your login keychain, then refresh Runway. -- **"Claude Desktop login found"** (neutral, like the Claude Code one) — connect, and choose **Always Allow** if macOS asks for access to `Claude Safe Storage`. -- **"Keychain access to the Claude Desktop login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. -- **"Claude Desktop login is stale"** (an amber warning on the Claude header) — open Claude Desktop so it can renew the login, then refresh Runway. -- **"Claude login needs renewal"** (an amber warning on the Claude header) — every stored login has an expired or revoked token, and Runway's own renewal couldn't recover it (usually the refresh token itself is revoked). Open Claude Code (it mints a fresh login on launch), then refresh Runway. The spend tiles keep working in the meantime. -- **"Re-login for live usage"** (an amber warning on the Claude header) — your saved login can authenticate for inference but can't read your subscription limits, because it lacks the `user:profile` access (this is what an inference-only token from `claude setup-token` carries). Run `claude` and sign in again with your Claude account, then refresh; the spend tiles keep working in the meantime. -- **"Updates blocked by Anthropic"** (an amber warning on the Claude header) — the usage API is throttling Runway. It keeps the last values from the same login, shows when it will retry, and backs off in the meantime. A different login starts with a fresh cache and cooldown. This is the one header warning you cannot click to refresh: manual refreshes extend the block, so the symbol stays a plain notice until the cooldown passes. -- **Spend tiles show "No data"** — Runway found no Claude Code logs in the last 30 days. If your logs live somewhere custom, set `CLAUDE_CONFIG_DIR` so both Claude Code and Runway look in the same place. +- **"Not logged in"**: run `claude` to sign in, then refresh. If local session logs exist, the spend tiles still show. Session and Weekly stay empty until you sign in. +- **"Claude Code login found"** (neutral key glyph / **Connect** button): the login exists but has not been loaded this session. Connect, and choose **Always Allow** if macOS asks for access to `Claude Code-credentials`. +- **"Keychain access to the Claude Code login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **"Claude Code credentials couldn't be checked"**: unlock your login keychain, then refresh. +- **"Claude Desktop login found"** (neutral): connect, and choose **Always Allow** if macOS asks for access to `Claude Safe Storage`. +- **"Keychain access to the Claude Desktop login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **"Claude Desktop login is stale"** (amber warning): open Claude Desktop so it can renew the login, then refresh. +- **"Claude login needs renewal"** (amber warning): every stored login has an expired or revoked token, and Runway's own renewal could not recover it (usually the refresh token itself is revoked). Open Claude Code, then refresh. The spend tiles keep working. +- **"Re-login for live usage"** (amber warning): your saved login can authenticate for inference but cannot read your subscription limits, because it lacks `user:profile` access (an inference-only token from `claude setup-token`). Run `claude` and sign in again with your Claude account, then refresh. The spend tiles keep working. +- **"Updates blocked by Anthropic"** (amber warning): the usage API is throttling Runway. It keeps the last values, shows when it will retry, and backs off. A different login starts with a fresh cache and cooldown. This is the one header warning you cannot click to refresh, because manual refreshes extend the block. +- **Spend tiles show "No data"**: Runway found no Claude Code logs in the last 30 days. If your logs live somewhere custom, set `CLAUDE_CONFIG_DIR` so both Claude Code and Runway look in the same place. ## Under the hood -`GET https://api.anthropic.com/api/oauth/usage` with the selected OAuth token. An already-expired token gets one guarded renewal at the token endpoint (`POST https://platform.claude.com/v1/oauth/token`, Claude Code's own public client), with the rotated credential written back to its store — see the renewal rules above. If a token is expired or revoked and renewal declines, Runway tries the next credential source, and when none is left it shows the renewal notice over the local spend tiles. +`GET https://api.anthropic.com/api/oauth/usage` with the selected OAuth token. An already-expired token gets one guarded renewal at the token endpoint (`POST https://platform.claude.com/v1/oauth/token`, Claude Code's own public client), with the rotated credential written back to its store. If a token is expired or revoked and renewal declines, Runway tries the next credential source, and when none is left it shows the renewal notice over the local spend tiles. -When the 5-hour session window has no usage yet, the Session row shows **Not started** on the trailing label; hover explains that the session begins after your first message. +When the 5-hour session window has no usage yet, the Session row shows **Not started**. Hover it for an explanation. diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 127458bd9..0a39b5ed2 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -8,78 +8,73 @@ Tracks your ChatGPT/Codex subscription limits using the login from the Codex CLI |---|---| | Session | 5-hour rolling window usage | | Weekly | 7-day window usage | -| Spark / Spark Weekly | GPT-5.3-Codex-Spark model limits — a 5-hour and a weekly window. Shown only when your account has the limit (otherwise "No data"), and off by default | -| Rate Limit Resets | On-demand rate-limit reset credits, shown as a count (e.g. `2 available`) with a colored dot for the soonest expiry; hover the value for a timeline of each credit's expiry | -| Extra Usage | Flex credits, shown verbatim as dollars + credits (e.g. `$31.84 · 796 credits`) | +| Spark / Spark Weekly | GPT-5.3-Codex-Spark model limits, a 5-hour and a weekly window. Shown only when your account has the limit (otherwise "No data"). Off by default | +| Rate Limit Resets | On-demand rate-limit reset credits, shown as a count (`2 available`) with a colored dot for the soonest expiry. Hover the value for a timeline of each credit's expiry | +| Extra Usage | Flex credits, shown as dollars and credits (`$31.84 · 796 credits`) | | Today / Yesterday / Last 30 Days | Local spend, as cost, tokens, or both (see below) | When Codex reports your plan name, Runway shows it beside the provider name. `self_serve_business_prolite` is shown as **Business Premium**. ## Where credentials come from -Sign in with the Codex CLI (`codex`); Runway reads the same `auth.json` file or home-scoped OS keyring item (`$CODEX_HOME` respected). All Codex credentials are strictly read-only to Runway: it never refreshes a token and never writes `auth.json` or the keyring — the `codex` CLI owns the login and its rotation. When the token lapses, the card shows **"Codex login needs renewal"**: run `codex` (it renews its own login), then refresh Runway. The local spend tiles keep working the whole time. +Sign in with the Codex CLI (`codex`). Runway reads the same `auth.json` file or home-scoped OS keyring item (`$CODEX_HOME` respected). Codex credentials are read-only to Runway. It never refreshes a token and never writes `auth.json` or the keyring. The `codex` CLI owns the login. When the token lapses, the card shows **"Codex login needs renewal"**. Run `codex` (it renews its own login), then refresh Runway. The spend tiles keep working. -Automatic refreshes never request the keyring secret. After launch or a credential change, the card -offers a neutral **Connect** action (not a warning); that deliberate read is cached in memory for the running app session while the item's -non-secret metadata remains unchanged. Choose **Always Allow** to avoid a dialog on future manual reads. If the -login keychain itself can't be inspected (it's locked, say), the card asks you to unlock it instead. +Automatic refreshes never request the keyring secret. After launch or a credential change, the card shows a neutral **Connect** action. That manual read is cached in memory for the running session while the item's non-secret metadata is unchanged. Choose **Always Allow** to avoid a dialog on future manual reads. If the login keychain cannot be inspected (it is locked, say), the card asks you to unlock it. ## Multiple accounts -Runway discovers Codex homes at launch and gives every distinct ChatGPT account its own card. Each card has isolated limits, plan, spend logs, cached data, and reset-credit actions. If the same account is signed in under more than one home, Runway keeps one card and combines those homes' session logs instead of duplicating it. Pi's Codex usage and OpenCode's ChatGPT OAuth usage identify only the provider family, so Runway assigns those slices to the account currently occupying the default Codex home; when no account holds that badge, it leaves the ambiguous slices unattributed. +Runway discovers Codex homes at launch and gives every distinct ChatGPT account its own card. Each card has its own limits, plan, spend logs, cached data, and reset-credit actions. If the same account is signed in under more than one home, Runway keeps one card and combines those homes' session logs. Pi's Codex usage and OpenCode's ChatGPT OAuth usage identify only the provider family, so Runway assigns those slices to the account in the default Codex home. When no account holds the default home, those slices are left unattributed. -Discovery checks the normal `~/.codex` and `~/.config/codex` homes, dot-directories in your home folder, directories directly under `~/.config`, and every explicit entry in `CODEX_HOME`. Runway accepts a comma-separated `CODEX_HOME` list for discovery; an individual Codex CLI process must still use one home at a time. For example: +Discovery checks `~/.codex` and `~/.config/codex`, dot-directories in your home folder, directories directly under `~/.config`, and every entry in `CODEX_HOME`. Runway accepts a comma-separated `CODEX_HOME` list for discovery. A single Codex CLI process still uses one home at a time. For example: ```sh CODEX_HOME="$HOME/.codex-work" codex CODEX_HOME="$HOME/.codex-personal" codex ``` -A discovered home must contain a usable OAuth login that names its account through Codex's own account id. For file storage, that identity comes directly from `auth.json`. For keyring storage, a user-attended **Refresh All** reads the exact home-scoped item and binds the account identity to that item's non-secret fingerprint; the card appears on the next launch. Replacing the keyring item invalidates the binding, so the home stays hidden until the new login is safely rebound. +A discovered home must contain a usable OAuth login that names its account through Codex's own account id. For file storage, that identity comes from `auth.json`. For keyring storage, a user-attended **Refresh All** reads the exact home-scoped item and binds the account identity to that item's non-secret fingerprint. The card appears on the next launch. Replacing the keyring item invalidates the binding, so the home stays hidden until the new login is rebound. -Runway never treats a directory name as identity. Every card is pinned to one credential home and reads only that home's original file or keyring item. That keeps a token, session log, cached snapshot, or reset claim from crossing between accounts when homes are added, removed, or swapped. +Runway never treats a directory name as identity. Every card is pinned to one credential home and reads only that home's file or keyring item. That keeps tokens, session logs, cached snapshots, and reset claims from crossing between accounts when homes are added, removed, or swapped. -With one discovered account, the default name is simply "Codex." With multiple accounts, every card -includes its account email, including the first/default card. An organization name appears after the -email when available. If two active accounts still have the same label, Runway adds a short stable -account code. If the only login lives in a custom home, it is the sole Codex card; Runway does not add -an unscoped card beside it. +With one account, the default name is "Codex". With multiple accounts, every card includes its account email, including the first card. An organization name appears after the email when available. If two accounts still have the same label, Runway adds a short stable account code. If the only login lives in a custom home, it is the sole Codex card. -Additional cards use stable ids such as `codex@ab12cd34`. You can rename any Codex card from its context menu or Customize. CLI and local API queries for `codex` return every active Codex account card; querying the full card id selects one. +Additional cards use stable ids such as `codex@ab12cd34`. You can rename any Codex card from its context menu or Customize. CLI and local API queries for `codex` return every Codex card. Querying the full card id selects one. ## The spend tiles -Today / Yesterday / Last 30 Days are computed **locally**: Runway reads the Codex CLI's session rollouts under `~/.codex/sessions/` and `archived_sessions/` (or `$CODEX_HOME`) itself — no external tools needed. Symlinks are followed, so a Codex home linked into a synced location (say, a Dropbox folder) is read all the same. Codex usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too: Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Codex usage there into the same tiles and trend. pi records its own per-message cost when it has one, so those dollars come straight from pi; zero-cost Codex rows (subscription usage pi doesn't impute) are estimated with the same Codex request rules as native logs. The same applies when OpenCode uses its built-in ChatGPT Pro/Plus OAuth login: Runway reads the `openai` rows from OpenCode's local database and attributes them to Codex. OpenCode API-key traffic is not included. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); sessions that ran on the fast/priority service tier — as recorded in each session's own log — use the fast rates for exactly those turns. Older logs without tier metadata, and everything else, price at standard rates; Runway does not consult the current `config.toml` setting, so a tier change never reprices past days. Auto-review usage keeps its `codex-auto-review` name in the model breakdown, while its cost uses the dated model fallback available for that event. The token counts themselves are measured. Subagent and forked sessions copy their parent session's token history into their own log; Runway recognizes those copies and counts each token once, no matter how many subagents a session spawns. No log data leaves your Mac. +Today, Yesterday, and Last 30 Days are computed locally. Runway reads the Codex CLI's session rollouts under `~/.codex/sessions/` and `archived_sessions/` (or `$CODEX_HOME`). Symlinks are followed. Codex usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too. Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Codex usage there into the same tiles and trend. pi records its own per-message cost when it has one, so those dollars come from pi. Zero-cost Codex rows (subscription usage pi does not price) are estimated with the same Codex request rules as native logs. The same applies when OpenCode uses its built-in ChatGPT Pro/Plus OAuth login: Runway reads the `openai` rows from OpenCode's local database and attributes them to Codex. OpenCode API-key traffic is not included. Days are grouped in your Mac's local time zone. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`). A day with no usage reads **No data**. -For supported GPT-5.4, GPT-5.5, GPT-5.6, and GPT-6 models, requests above 272k input tokens use OpenAI's long-context rates for the whole request. These Codex-specific request rules apply consistently to native Codex logs and zero-cost Codex OAuth usage imported from pi or OpenCode. Cached input uses the published cache-read discount when the pricing source provides one; otherwise it is estimated at the full input rate. Fast/priority estimates use each model's published Codex multiplier (for example, GPT-5.5 uses 2.5×); model names ending in `-fast` are normalized to their unscaled base rate before that multiplier is applied once. +The dollars are estimated from token counts at API rates using the shared [model pricing](../pricing.md). Sessions that ran on the fast/priority service tier, as recorded in each session's own log, use the fast rates for those turns. Older logs without tier metadata price at standard rates. Runway does not consult the current `config.toml`, so a tier change never reprices past days. Auto-review usage keeps its `codex-auto-review` name in the model breakdown, and its cost uses the dated model fallback available for that event. The token counts are measured. Subagent and forked sessions copy their parent session's token history into their own log. Runway recognizes those copies and counts each token once. No log data leaves your Mac. + +For supported GPT-5.4, GPT-5.5, GPT-5.6, and GPT-6 models, requests above 272k input tokens use OpenAI's long-context rates for the whole request. These Codex request rules apply the same way to native Codex logs and to zero-cost Codex OAuth usage imported from pi or OpenCode. Cached input uses the published cache-read discount when the pricing source provides one, otherwise the full input rate. Fast/priority estimates use each model's published Codex multiplier (for example 2.5× for GPT-5.5). Model names ending in `-fast` are normalized to their base rate before that multiplier is applied once. ## Troubleshooting -- **"Not logged in"** — run `codex` and sign in, then refresh. -- **"Codex login needs renewal"** — the stored token has expired or was revoked. Runway never renews Codex's tokens itself, so run `codex` (it refreshes its login when it starts), then refresh Runway. The spend tiles keep working in the meantime. -- **"Codex login found in Keychain"** (a neutral key glyph, not a warning) — the login hasn't been loaded this app session. Connect, and choose **Always Allow** when macOS asks for access to `Codex Auth`. -- **"Keychain access to the Codex login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. -- **API-key-only setups** can't read subscription usage — sign in with your ChatGPT account instead. -- **Spend tiles show "No data"** — Runway found no qualifying Codex usage in Codex, pi, or OpenCode logs from the last 30 days. If your Codex home lives somewhere custom, set `CODEX_HOME` so both the Codex CLI and Runway look in the same place. -- **OpenCode usage is missing** — OpenCode must currently have an `openai` OAuth credential in its `auth.json`. An OpenAI API key is deliberately excluded from Codex subscription totals. -- **A custom home doesn't become a separate card** — confirm it has a ChatGPT OAuth login and either an `auth.json`, `config.toml`, or sessions directory that lets discovery recognize the home. API-key-only, tokenless, and nameless file credentials are skipped rather than guessed. For a keyring-only home, choose **Refresh All** and approve its exact `Codex Auth` item if macOS asks, then relaunch Runway so the bound account can become a card. +- **"Not logged in"**: run `codex` and sign in, then refresh. +- **"Codex login needs renewal"**: the stored token has expired or was revoked. Runway never renews Codex tokens, so run `codex` (it refreshes its login on start), then refresh Runway. The spend tiles keep working. +- **"Codex login found in Keychain"** (neutral key glyph): the login has not been loaded this session. Connect, and choose **Always Allow** when macOS asks for access to `Codex Auth`. +- **"Keychain access to the Codex login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **API-key-only setups** cannot read subscription usage. Sign in with your ChatGPT account instead. +- **Spend tiles show "No data"**: Runway found no qualifying Codex usage in Codex, pi, or OpenCode logs from the last 30 days. If your Codex home is custom, set `CODEX_HOME` so both the Codex CLI and Runway look in the same place. +- **OpenCode usage is missing**: OpenCode must have an `openai` OAuth credential in its `auth.json`. An OpenAI API key is excluded from Codex subscription totals. +- **A custom home doesn't become a separate card**: confirm it has a ChatGPT OAuth login and an `auth.json`, `config.toml`, or sessions directory that lets discovery recognize the home. API-key-only, tokenless, and nameless file credentials are skipped. For a keyring-only home, choose **Refresh All**, approve its `Codex Auth` item if macOS asks, then relaunch Runway so the bound account can become a card. ## Under the hood -`GET https://chatgpt.com/backend-api/wham/usage` with the Codex OAuth token, read-only: Runway never calls the token endpoint, and a 401/403 shows the renewal notice instead of retrying. Session and Weekly are classified by each usage window's duration rather than by its primary/secondary slot. This matters when Codex temporarily removes one limit and moves the remaining weekly window into the primary slot. Payloads without a recognized duration retain the primary-as-Session and secondary-as-Weekly compatibility fallback; response headers fill percentages missing from the corresponding window. +`GET https://chatgpt.com/backend-api/wham/usage` with the Codex OAuth token, read-only. Runway never calls the token endpoint. A 401/403 shows the renewal notice instead of retrying. Session and Weekly are classified by each usage window's duration rather than by its primary/secondary slot. This matters when Codex temporarily removes one limit and moves the remaining weekly window into the primary slot. Payloads without a recognized duration fall back to primary-as-Session and secondary-as-Weekly. Response headers fill percentages missing from the corresponding window. -Spark and Spark Weekly come from the same response's `additional_rate_limits` array — model-specific limits that reuse the duration-based Session/Weekly classification. Runway surfaces the entry whose name identifies GPT-5.3-Codex-Spark as those two meters; accounts without the limit simply omit the entry, so the rows read "No data". Other model limits in that array aren't shown. +Spark and Spark Weekly come from the same response's `additional_rate_limits` array, using the same duration-based classification. Runway shows the entry whose name identifies GPT-5.3-Codex-Spark. Accounts without the limit omit the entry, so the rows read "No data". Other model limits in that array are not shown. -Runway preserves Codex's reported `used_percent` verbatim. If the API reports 1% used for an untouched window, the app shows 99% left; if it reports 0%, the app shows 100% left. Codex rows use the normal reset label rather than inferring a special "Not started" state. Burn-rate pacing still waits until enough of the window has elapsed — and until something has actually been used — to make a useful projection. +Runway preserves Codex's reported `used_percent`. If the API reports 1% used for an untouched window, the app shows 99% left. Codex rows use the normal reset label rather than a "Not started" state. Pacing waits until enough of the window has elapsed, and something has been used, to make a projection. -The "Rate Limit Resets" row shows the on-demand reset-credit count, e.g. `2 available`, with a colored dot for the soonest credit's expiry — blue beyond a week, yellow within a week, red within 48 hours. Runway also makes a best-effort `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits` call — the dedicated endpoint that lists each credit's expiry. Hover the value and a popover shows those as a timeline of each reset, soonest-first — a numbered color dot, the exact expiry time (`Jul 12 at 5:30 PM`), and the countdown to it (`12d 18h`) on the trailing edge. When no credits are available it reads `0 available` and the popover shows `You have no rate limit resets`. If the dedicated call fails, the row falls back to the count embedded in the usage body (`rate_limit_reset_credits.available_count`); since that body carries no per-credit expiries, the popover states the count (`N available`) and notes that expiry times are unavailable rather than implying there are none. +The "Rate Limit Resets" row shows the reset-credit count (`2 available`) with a colored dot for the soonest expiry: blue beyond a week, yellow within a week, red within 48 hours. Runway also makes a best-effort `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits` call, the endpoint that lists each credit's expiry. Hover the value for a timeline of those resets, soonest first: a numbered color dot, the exact expiry time (`Jul 12 at 5:30 PM`), and the countdown (`12d 18h`). With no credits it reads `0 available` and the popover says `You have no rate limit resets`. If the dedicated call fails, the row falls back to the count in the usage body (`rate_limit_reset_credits.available_count`). That body has no per-credit expiries, so the popover shows the count and notes that expiry times are unavailable. ### Using a reset from the popover -You can also spend a reset credit right from that popover — the same claim the Codex CLI's "Usage limit resets" picker performs. Hover a credit in the timeline and a **Use** button appears; clicking it expands that credit into an inline confirmation ("Immediately reset your usage limits. This can't be undone.") with **Reset** / **Cancel**. Confirming claims that exact credit from that card's account and immediately resets its 5-hour and weekly windows; the app then refreshes only that Codex card so the meters and the remaining count reflect it before the success line ("Reset claimed. Enjoy!") appears. +You can spend a reset credit from that popover, the same claim the Codex CLI's "Usage limit resets" picker performs. Hover a credit in the timeline and a **Use** button appears. Clicking it expands that credit into an inline confirmation ("Immediately reset your usage limits. This can't be undone.") with **Reset** / **Cancel**. Confirming claims that credit from that card's account and resets its 5-hour and weekly windows. The app then refreshes only that Codex card so the meters and remaining count update before the success line ("Reset claimed. Enjoy!") appears. Safeguards, because a claim is irreversible: -- Claiming is always a deliberate two-click flow behind the hover popover — nothing is ever claimed automatically. +- Claiming is always a two-click flow behind the hover popover. Nothing is claimed automatically. - Each claim targets one explicit credit (re-matched against a fresh credit list at claim time) and carries an idempotency key, so a retry after a network error can never spend a second credit. -- If the credit was meanwhile used elsewhere (CLI or web) the popover says it's no longer available and refreshes; if your usage doesn't need a reset, Codex refuses without spending the credit and the popover says so. After a claim resets usage, the remaining Use buttons disable ("nothing to reset") until the popover is reopened. +- If the credit was used elsewhere meanwhile (CLI or web), the popover says it is no longer available and refreshes. If your usage does not need a reset, Codex refuses without spending the credit and the popover says so. After a claim resets usage, the remaining Use buttons disable ("nothing to reset") until the popover is reopened. diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index f7af24614..5c8a762d2 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -1,90 +1,70 @@ # Copilot -Tracks your GitHub Copilot quota using a GitHub token that Copilot tooling already left on your machine. No login flow, no browser cookies. +Tracks your GitHub Copilot quota using a GitHub token that Copilot tooling already left on your machine. No login flow and no browser cookies. ## What it tracks | Metric | Meaning | |---|---| -| Credits | Share of your monthly AI-credit allotment used (the headline meter). On org-managed seats with no allotment, a plain count of your own credits used this cycle | +| Credits | Share of your monthly AI-credit allotment used. On org-managed seats with no allotment, a plain count of your own credits used this cycle | | Extra Usage | Premium interactions used beyond your included credits, once extra spend is enabled | | AI Credits Used | Total AI credits your organization used this month, with included/additional breakdown | | Additional Spend | Dollars your organization was billed beyond its included AI credits | | Chat | Chat-message quota used | | Completions | Code-completion quota used | -Runway adapts the card to the account instead of showing every possible Copilot metric as "No data": +Runway adapts the card to the account instead of showing every Copilot metric as "No data": - **Individual paid plans** show Credits and, when enabled, Extra Usage. - **Individual free plans** show Chat and Completions. - **Business and Enterprise seats** show AI Credits Used and Additional Spend, plus your own Credits count when the seat reports one. -Metrics that GitHub does not expose for the current account type are hidden. The applicable usage rows are Always Visible; none of the organization metrics are pinned to the menu bar by default. Percentage meters show percent used and, when the response includes one, a countdown to the next reset. The plan name (Pro, Business, Free, …) shows next to the provider. - -Since June 2026 GitHub Copilot bills all plans by **AI credits**, so what each account shows differs by plan: - -- **Paid plans** meter the credit pool — so you see Credits (and Extra Usage if you've turned on additional spend). Chat and completions are unlimited on paid plans, so those inapplicable rows are hidden. -- **Free plans** have no credits; instead you see the fixed Chat and Completions quotas GitHub reports. - Both rows remain available if a refresh temporarily omits one bucket; the omitted metric shows No data - until GitHub reports it again. -- **Org-managed seats (Copilot Business / Enterprise assigned by an organization)** return no per-seat percent quota. If the response's premium bucket carries a real `credits_used` count, Runway shows it as **Credits** — a plain count, not a percentage, since there's no allotment to divide by. This is your *own* consumption and needs no special access. Runway also looks the usage up at the seat's billing entity—its organization, or its enterprise when billing is consolidated—and shows **AI Credits Used** (total organization usage, broken into included and additional credits) and **Additional Spend** (dollars billed beyond the included pool). Caveats: - - The numbers are **organization-wide**, not your personal share — GitHub doesn't expose per-seat usage. - - Reading an org's billing requires you to be an **org owner or billing manager**. Regular members see a clear managed-account message instead of personal "No data" placeholders — plus their own Credits count when the response carries one. - - When Copilot identifies the seat's organization, Runway checks its enterprise before it accepts an - empty organization report, because consolidated usage is billed at the enterprise level. If a - proven enterprise association stays unreadable — or the seat is a Copilot **Enterprise** seat and - the credential can't see enterprise associations at all — Runway keeps the managed-account state. - For a **Business** seat, when no enterprise claims the organization (or associations can't be - read), a readable empty organization report stands. At the start of a billing month the card shows - zero credits used, not the managed-account message. As soon as usage happens under that login, the - org report carries the real numbers on the next refresh. An unrelated empty report is never - attributed to the seat. -- AI Credits Used is shown as a plain count, not a percentage. The API reports total, discounted/included, and additional usage, but not the organization's full available pool; Runway doesn't fabricate a denominator. - -A dollar credit figure (e.g. "$12 of $15 used") is not shown: GitHub only exposes it through the logged-in web billing page, which requires browser cookies — the Copilot provider does not read browser cookies. Editors like VS Code show the same credit *percentage* from this endpoint, not a dollar amount. +Metrics GitHub does not expose for the current account type are hidden. The applicable usage rows are Always Visible. None of the organization metrics are pinned to the menu bar by default. Percentage meters show percent used and, when the response includes one, a countdown to the next reset. The plan name (Pro, Business, Free) shows next to the provider. + +Since June 2026 GitHub Copilot bills all plans by AI credits: + +- **Paid plans** meter the credit pool, so you see Credits (and Extra Usage if you have turned on additional spend). Chat and completions are unlimited on paid plans, so those rows are hidden. +- **Free plans** have no credits. You see the fixed Chat and Completions quotas GitHub reports. Both rows stay available if a refresh omits one bucket. The omitted metric shows No data until GitHub reports it again. +- **Org-managed seats** (Copilot Business or Enterprise assigned by an organization) return no per-seat percent quota. If the response's premium bucket carries a `credits_used` count, Runway shows it as **Credits**, a plain count, since there is no allotment to divide by. This is your own consumption and needs no special access. Runway also looks up usage at the seat's billing entity (its organization, or its enterprise when billing is consolidated) and shows **AI Credits Used** and **Additional Spend**. Caveats: + - The numbers are organization-wide, not your personal share. GitHub does not expose per-seat usage. + - Reading an org's billing requires you to be an org owner or billing manager. Regular members see a managed-account message instead of "No data" placeholders, plus their own Credits count when the response carries one. + - When Copilot identifies the seat's organization, Runway checks its enterprise before accepting an empty organization report, because consolidated usage is billed at the enterprise level. If a proven enterprise association stays unreadable, or the seat is an Enterprise seat and the credential cannot see enterprise associations at all, Runway keeps the managed-account state. For a Business seat, when no enterprise claims the organization (or associations cannot be read), a readable empty organization report stands. At the start of a billing month the card shows zero credits used, not the managed-account message. An unrelated empty report is never attributed to the seat. +- AI Credits Used is a plain count, not a percentage. The API reports total, included, and additional usage, but not the organization's full pool, and Runway does not invent a denominator. + +A dollar credit figure ("$12 of $15 used") is not shown. GitHub only exposes it through the logged-in web billing page, which requires browser cookies, and the Copilot provider does not read browser cookies. Editors like VS Code show the same credit percentage from this endpoint, not a dollar amount. ## Where credentials come from Checked in this order (prompt-free files first, Keychain last): -1. Copilot editor token: `~/.config/github-copilot/apps.json` (older `hosts.json`) — written by the VS Code / JetBrains / Neovim Copilot plugins. +1. Copilot editor token: `~/.config/github-copilot/apps.json` (older `hosts.json`), written by the VS Code, JetBrains, and Neovim Copilot plugins. 2. GitHub CLI config: `~/.config/gh/hosts.yml` (`oauth_token`), when `gh` stores its token in a file. -3. GitHub CLI Keychain item (service `gh:github.com`), when `gh` stores its token in the system keyring. - Automatic refreshes never request its secret. After launch or a credential change the card - offers a neutral **Connect** action; Runway reuses that value in memory for the running app - session while the item's non-secret metadata remains unchanged. Choose - **Always Allow** to avoid a dialog on future manual reads. - -The editor token stays preferred for the Copilot quota endpoint. For organization and enterprise -billing, Runway tries the GitHub CLI credential first when Copilot identifies the seat organization, -because it can carry the required organization or enterprise permissions, then falls back to the editor -token if necessary. An empty report remains provisional until both credentials have been tried, so a -credential with consolidated enterprise access can still supply the actual totals. When the seat -organization is unknown, Runway keeps membership discovery on the same credential that produced the -Copilot card so another local GitHub account cannot be mixed in. +3. GitHub CLI Keychain item (service `gh:github.com`), when `gh` stores its token in the system keyring. Automatic refreshes never request its secret. After launch or a credential change, the card shows a neutral **Connect** action. Runway reuses that value in memory for the running session while the item's non-secret metadata is unchanged. Choose **Always Allow** to avoid a dialog on future manual reads. + +The editor token is preferred for the Copilot quota endpoint. For organization and enterprise billing, Runway tries the GitHub CLI credential first when Copilot identifies the seat organization, because it can carry the required permissions, then falls back to the editor token. An empty report stays provisional until both credentials have been tried, so a credential with consolidated enterprise access can still supply the totals. When the seat organization is unknown, Runway keeps membership discovery on the credential that produced the Copilot card, so another local GitHub account cannot be mixed in. ### Setup -If usage doesn't appear, authenticate with the GitHub CLI: +If usage does not appear, authenticate with the GitHub CLI: ```bash brew install gh # if needed gh auth login # choose GitHub.com and follow the prompts ``` -Using Copilot in a supported editor is enough on its own — the editor writes the token to `apps.json`. +Using Copilot in a supported editor is enough on its own. The editor writes the token to `apps.json`. ## Troubleshooting -- **"Sign in to GitHub Copilot…"** — no token was found. Sign in to Copilot in your editor, or run `gh auth login`. -- **"GitHub login found in Keychain"** (a neutral key glyph / **Connect** button, not a warning) — `gh` keeps its token in the system keyring and it hasn't been loaded this app session. Connect; choose **Always Allow** to avoid a dialog on future manual reads. -- **"Keychain access to the GitHub login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. -- **"GitHub login couldn't be read"** — the login keychain itself is unavailable (locked, most often). Unlock it and refresh; approving nothing would fix this one. -- **"GitHub token invalid or expired"** — the token was rejected (401/403). Re-authenticate with `gh auth login`. -- **"Managed by Your Organization"** — GitHub doesn't expose a live per-seat percent quota for Business/Enterprise, and none of the locally available credentials could read the relevant organization or enterprise billing. Your own Credits count still shows when the seat reports one. Organization reporting requires organization billing access; consolidated reporting also requires enterprise read and billing access. Some editor-plugin and GitHub CLI tokens do not carry those scopes. +- **"Sign in to GitHub Copilot…"**: no token was found. Sign in to Copilot in your editor, or run `gh auth login`. +- **"GitHub login found in Keychain"** (neutral key glyph / **Connect** button): `gh` keeps its token in the system keyring and it has not been loaded this session. Connect, and choose **Always Allow** to avoid a dialog on future manual reads. +- **"Keychain access to the GitHub login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **"GitHub login couldn't be read"**: the login keychain is unavailable, most often locked. Unlock it and refresh. +- **"GitHub token invalid or expired"**: the token was rejected (401/403). Re-authenticate with `gh auth login`. +- **"Managed by Your Organization"**: GitHub does not expose a per-seat percent quota for Business/Enterprise, and none of the local credentials could read the organization or enterprise billing. Your own Credits count still shows when the seat reports one. Organization reporting requires organization billing access. Consolidated reporting also requires enterprise read and billing access. Some editor-plugin and GitHub CLI tokens do not carry those scopes. ## Under the hood -`GET https://api.github.com/copilot_internal/user` with the standard Copilot client headers (API version `2025-04-01`). The response reports each bucket as percent *remaining*; the meters show percent *used*. +`GET https://api.github.com/copilot_internal/user` with the standard Copilot client headers (API version `2025-04-01`). The response reports each bucket as percent remaining. The meters show percent used. -For org-managed seats (identified by the token-based-billing placeholder in that response), Runway first uses the Copilot account response's organization list to query `GET /organizations/{org}/settings/billing/ai_credit/usage?product=Copilot`. GitHub defaults that endpoint to the current year and month. If an associated organization returns 403, 404, or an empty report, Runway resolves all enterprises visible to the token through GitHub GraphQL, verifies which enterprise owns that seat organization, and queries the enterprise AI-credit endpoint filtered to that organization and Copilot. This lets an authorized enterprise billing manager see consolidated totals even when they do not administer the seat organization. Rate-limited and other retryable REST or GraphQL failures fail that refresh — the card keeps its last-good numbers with the usual staleness/warning treatment instead of replacing them; only explicit access errors show the managed-account state. If the Copilot response has no organization association, Runway falls back to `GET /user/orgs`, but only positive Copilot usage—not an empty current or cached report—can identify the seat's organization. Other AI products are excluded at the API boundary and ignored by the mapper. An org is remembered only after it reports Copilot usage. +For org-managed seats (identified by the token-based-billing placeholder in that response), Runway first uses the response's organization list to query `GET /organizations/{org}/settings/billing/ai_credit/usage?product=Copilot`. GitHub defaults that endpoint to the current year and month. If an associated organization returns 403, 404, or an empty report, Runway resolves all enterprises visible to the token through GitHub GraphQL, verifies which enterprise owns that seat organization, and queries the enterprise AI-credit endpoint filtered to that organization and Copilot. This lets an enterprise billing manager see consolidated totals even when they do not administer the seat organization. Rate-limited and other retryable REST or GraphQL failures fail that refresh, so the card keeps its last-good numbers with the usual warning treatment. Only explicit access errors show the managed-account state. If the Copilot response has no organization association, Runway falls back to `GET /user/orgs`, but only positive Copilot usage (not an empty current or cached report) can identify the seat's organization. Other AI products are excluded at the API boundary and ignored by the mapper. An org is remembered only after it reports Copilot usage. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index ea361df07..1ef9db679 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -11,32 +11,29 @@ Tracks your Cursor plan usage using the login from the Cursor app. | Requests | Optional copy of the included request count vs. cap for custom layouts | | Cursor Models | Cursor-native model usage percent (Grok, Composer) | | Other Models | Third-party model usage percent | -| Grok Bot | Grok Bot weekly usage percent and reset countdown; enabled by default, below the caret | -| Extra Usage | On-demand spend; user-scoped when available, otherwise the team aggregate; shown as a meter when Cursor returns a limit | +| Grok Bot | Grok Bot weekly usage percent and reset countdown. Enabled by default, below the caret | +| Extra Usage | On-demand spend, user-scoped when available, otherwise the team aggregate. Shown as a meter when Cursor returns a limit | When Cursor reports your plan name, Runway shows it beside the provider name. -Grok Bot has its own weekly allowance, separate from Cursor's billing-cycle meter. It uses the existing Cursor login — signing into the Grok CLI is not required. Accounts without a personal included allowance (including pooled enterprise seats) hide the meter. +Grok Bot has its own weekly allowance, separate from Cursor's billing-cycle meter. It uses the existing Cursor login. Signing into the Grok CLI is not required. Accounts without a personal included allowance (including pooled enterprise seats) hide the meter. ## Where credentials come from -Just be signed into the Cursor app. Runway reads Cursor's local state database (and its keychain entries) for the session tokens. All Cursor credentials are strictly read-only to Runway: it never refreshes a token and never writes the state database or keychain — the Cursor app owns the login and its rotation. When the token lapses, the card shows **"Cursor login needs renewal"**: sign in again where that login lives — open the Cursor app, or run `agent login` if you use the Cursor CLI — then refresh Runway. Nothing extra to install or configure. +Be signed into the Cursor app. Runway reads Cursor's local state database and its keychain entries for the session tokens. Cursor credentials are read-only to Runway. It never refreshes a token and never writes the state database or keychain. The Cursor app owns the login. When the token lapses, the card shows **"Cursor login needs renewal"**. Sign in again where that login lives (open the Cursor app, or run `agent login` if you use the Cursor CLI), then refresh Runway. -Automatic refreshes never request the keychain secrets. After launch or a credential change, the card -offers a neutral **Connect** action (not a warning); that deliberate read is cached in memory for the running app session while the items' -non-secret metadata remains unchanged. Choose **Always Allow** to avoid a dialog on future manual reads. If the -login keychain itself can't be inspected (it's locked, say), the card asks you to unlock it instead. +Automatic refreshes never request the keychain secrets. After launch or a credential change, the card shows a neutral **Connect** action. That manual read is cached in memory for the running session while the items' non-secret metadata is unchanged. Choose **Always Allow** to avoid a dialog on future manual reads. If the login keychain cannot be inspected (it is locked, say), the card asks you to unlock it. ## The spend tiles -Today, Yesterday, Last 30 Days, and Usage Trend come from Cursor's usage export. Runway uses the exported token counts and shared model pricing to estimate the cost locally. Cursor's export can arrive late, so the newest figures can lag behind current activity. Runway leaves isolated malformed rows out instead of silently counting broken values as zero. A failed download, invalid export schema, or broken CSV structure leaves spend history unavailable for that refresh. Each failure is recorded in the diagnostic log without including the exported usage data. +Today, Yesterday, Last 30 Days, and Usage Trend come from Cursor's usage export. Runway uses the exported token counts and the shared model pricing to estimate cost locally. Cursor's export can arrive late, so the newest figures can lag current activity. Runway leaves malformed rows out instead of counting them as zero. A failed download, invalid export schema, or broken CSV leaves spend history unavailable for that refresh. Each failure is recorded in the log without the exported usage data. ## Troubleshooting -- **"Not logged in" / token errors** — open Cursor and make sure you're signed in, then refresh. -- **Some metrics missing** — Cursor omits fields depending on plan type; missing metrics simply show "No data". -- **Optional lookup failed** — plan, credit-grant, prepaid-balance, Grok Bot, and request-fallback failures stay nonfatal when primary usage is available. Runway records fixed, credential-free reasons in the diagnostic log. +- **"Not logged in" / token errors**: open Cursor and make sure you are signed in, then refresh. +- **Some metrics missing**: Cursor omits fields depending on plan type. Missing metrics show "No data". +- **Optional lookup failed**: plan, credit-grant, prepaid-balance, Grok Bot, and request-fallback failures are nonfatal when primary usage is available. Runway records fixed, credential-free reasons in the log. ## Under the hood -Connect RPC on `api2.cursor.sh` (dashboard usage and `DashboardService/GetSandUsageStatus` for Grok Bot), combined REST fallback at `cursor.com/api/usage` and `cursor.com/api/usage-summary` for Enterprise/team accounts, Stripe balance at `cursor.com/api/auth/stripe`, and the usage-events CSV export at `cursor.com/api/dashboard/export-usage-events-csv`. The fallback combines the included request allowance with structured percentages and user-scoped on-demand spend; neither REST response is treated as the whole account snapshot by itself. All requests are read-only against the stored token — a 401/403 shows the renewal notice instead of refreshing and retrying; optional endpoint failures stay nonfatal when the other fallback response is usable and are recorded in the diagnostic log. Per-day spend imputation uses exported token counts priced through the shared [model pricing](../pricing.md); Cursor-native models (`auto`, `composer-*`, …) come from its supplement layer, which maintainers sync from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md). +Connect RPC on `api2.cursor.sh` (dashboard usage and `DashboardService/GetSandUsageStatus` for Grok Bot), a combined REST fallback at `cursor.com/api/usage` and `cursor.com/api/usage-summary` for Enterprise and team accounts, Stripe balance at `cursor.com/api/auth/stripe`, and the usage-events CSV export at `cursor.com/api/dashboard/export-usage-events-csv`. The fallback combines the included request allowance with structured percentages and user-scoped on-demand spend. Neither REST response is treated as the whole account snapshot by itself. All requests are read-only against the stored token. A 401/403 shows the renewal notice instead of refreshing and retrying. Optional endpoint failures are nonfatal when the other fallback response is usable and are recorded in the log. Per-day spend uses exported token counts priced through the shared [model pricing](../pricing.md). Cursor-native models (`auto`, `composer-*`) come from its supplement layer, which maintainers sync from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md). diff --git a/docs/providers/devin.md b/docs/providers/devin.md index 71a4920ad..5ca5907e3 100644 --- a/docs/providers/devin.md +++ b/docs/providers/devin.md @@ -8,13 +8,13 @@ Tracks your Devin quota using the login from the Devin CLI or the Devin app. |---|---| | Weekly | Weekly quota used (falls back to the daily figure when Devin reports no weekly quota) | | Daily | Daily quota used (hidden when Devin hides the daily quota) | -| Extra Balance | Overage/extra-usage balance in dollars | +| Extra Balance | Overage or extra-usage balance in dollars | When Devin reports your plan name, Runway shows it beside the provider name. ## Where credentials come from -Checked in this order — whichever works first wins: +Checked in this order. The first that works wins: 1. Devin CLI credentials: `~/.local/share/devin/credentials.toml` (uses `windsurf_api_key`, and `api_server_url` when present) 2. The Devin app's local state database @@ -23,9 +23,9 @@ If the CLI credentials fail but the app is signed in with a different account, R ## Troubleshooting -- **"Not logged in"** — run `devin auth login`, or sign into the Devin app, then refresh. -- **Weekly shows the daily figure** — when Devin reports no separate weekly quota, the daily quota is shown in the Weekly row so it stays meaningful. +- **"Not logged in"**: run `devin auth login`, or sign into the Devin app, then refresh. +- **Weekly shows the daily figure**: when Devin reports no separate weekly quota, the daily quota is shown in the Weekly row. ## Under the hood -Connect RPC `GetUserStatus` on the configured API server (default `server.codeium.com`). Quota percentages arrive as "remaining" and are flipped to "used". No token refresh — a 401/403 switches to the next auth source instead. +Connect RPC `GetUserStatus` on the configured API server (default `server.codeium.com`). Quota percentages arrive as remaining and are flipped to used. No token refresh. A 401/403 switches to the next auth source. diff --git a/docs/providers/grok.md b/docs/providers/grok.md index 5ca138b25..60382639a 100644 --- a/docs/providers/grok.md +++ b/docs/providers/grok.md @@ -7,32 +7,32 @@ Tracks Grok Build credit usage using the login from the Grok CLI. | Metric | Meaning | |---|---| | Weekly | The shared weekly pool's usage percent (the limit Grok's unified billing enforces), with the weekly reset countdown | -| Extra Usage | Pay-as-you-go cap as a status (e.g. `2500 cap` or `Disabled`) | -| Rate Limit Resets | Banked usage-limit reset tokens, shown as a count (e.g. `1 available`) with a colored dot for the soonest expiry; hover the value for a timeline of each token's expiry | +| Extra Usage | Pay-as-you-go cap as a status (`2500 cap` or `Disabled`) | +| Rate Limit Resets | Banked usage-limit reset tokens, shown as a count (`1 available`) with a colored dot for the soonest expiry. Hover the value for a timeline of each token's expiry | | Today / Yesterday / Last 30 Days | Local cost and tokens estimated from Grok CLI session activity | When Grok reports your subscription tier, Runway shows it beside the provider name. -The weekly shared pool is the limit Grok enforces for unified-billing accounts (the old monthly credits meter is legacy and no longer shown). Accounts that haven't been migrated to unified billing have no weekly pool, so the Weekly tile reads "No data" there. +The weekly shared pool is the limit Grok enforces for unified-billing accounts. The old monthly credits meter is no longer shown. Accounts not yet migrated to unified billing have no weekly pool, so the Weekly tile reads "No data". ## Where credentials come from -Sign in once with the Grok CLI (`grok login`); Runway reads the same `~/.grok/auth.json`. Access tokens refresh automatically before expiry, and rotated tokens are written back to the file. +Sign in once with the Grok CLI (`grok login`). Runway reads the same `~/.grok/auth.json`. Access tokens refresh automatically before expiry, and rotated tokens are written back to the file. ## The spend tiles -Today / Yesterday / Last 30 Days are computed **locally** from Grok CLI's persisted session activity under `~/.grok/sessions/` (or `$GROK_HOME/sessions/`). Grok 1.x records measured token buckets and per-model totals when each turn completes; Runway reads those records directly, includes nested subagent and resumed sessions, and removes replayed turns from forked sessions. For older Grok CLI versions, Runway still falls back to `~/.grok/logs/unified.jsonl`. +Today, Yesterday, and Last 30 Days are computed locally from Grok CLI's session activity under `~/.grok/sessions/` (or `$GROK_HOME/sessions/`). Grok 1.x records measured token buckets and per-model totals when each turn completes. Runway reads those records, includes nested subagent and resumed sessions, and removes replayed turns from forked sessions. For older Grok CLI versions, Runway falls back to `~/.grok/logs/unified.jsonl`. -Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`), the same as Claude/Codex/Cursor. The dollars are estimated from measured token counts at public API rates using the shared [model pricing](../pricing.md) (that's the ⓘ), and these estimates are separate from the weekly subscription pool that Grok's billing API reports. No session data leaves your Mac. A period with no recorded usage reads "No data" rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. +Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`), the same as Claude, Codex, and Cursor. The dollars are estimated from measured token counts at public API rates using the shared [model pricing](../pricing.md). These estimates are separate from the weekly subscription pool that Grok's billing API reports. No session data leaves your Mac. A period with no recorded usage reads "No data". ## Troubleshooting -- **"Session expired" / auth errors** — run `grok login` again, then refresh. -- **Weekly shows "No data"** — your account still reports a monthly (non-weekly) period, meaning it hasn't been migrated to Grok's unified weekly billing yet. -- **Spend tiles show "No data"** — complete a Grok CLI turn so its usage is saved under `~/.grok/sessions/`, then refresh. On older Grok CLI versions, Runway needs token-bearing rows in `~/.grok/logs/unified.jsonl`. +- **"Session expired" / auth errors**: run `grok login` again, then refresh. +- **Weekly shows "No data"**: your account still reports a monthly period, which means it has not been migrated to unified weekly billing yet. +- **Spend tiles show "No data"**: complete a Grok CLI turn so its usage is saved under `~/.grok/sessions/`, then refresh. On older Grok CLI versions, Runway needs token-bearing rows in `~/.grok/logs/unified.jsonl`. ## Under the hood -`GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` for the weekly pool and pay-as-you-go cap — the exact call the Grok CLI itself makes — and `…/v1/settings` for the plan name; token refresh via `auth.x.ai`. A 401/403 triggers one token refresh and retry. +`GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` for the weekly pool and pay-as-you-go cap (the call the Grok CLI itself makes), and `…/v1/settings` for the plan name. Token refresh via `auth.x.ai`. A 401/403 triggers one token refresh and retry. -The "Rate Limit Resets" row comes from a best-effort `POST https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets` (gRPC-web, same Grok CLI OAuth token). That is the Settings → Usage "Reset Available" card: each still-valid token has an id and a `validity_end`. Hover the value for a timeline of those expiries, soonest-first — the same popover Codex uses, but read-only. Runway never redeems a Grok reset. If that RPC fails, the row is omitted rather than shown as `0 available`; a successful empty list reads `0 available`. +The "Rate Limit Resets" row comes from a best-effort `POST https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets` (gRPC-web, same Grok CLI OAuth token). That is the Settings → Usage "Reset Available" card. Each still-valid token has an id and a `validity_end`. Hover the value for a timeline of those expiries, soonest first, the same popover Codex uses but read-only. Runway never redeems a Grok reset. If that RPC fails, the row is omitted rather than shown as `0 available`. A successful empty list reads `0 available`. diff --git a/docs/providers/kimi.md b/docs/providers/kimi.md index 5c63a54c7..13429c27d 100644 --- a/docs/providers/kimi.md +++ b/docs/providers/kimi.md @@ -11,13 +11,9 @@ Tracks Kimi Code membership quota using the login from the official Kimi Code CL | Extra Usage Balance | Remaining prepaid Extra Usage balance | | Monthly Extra Usage | Extra Usage spent against the monthly cap, or the uncapped amount spent | -Kimi Code membership quota is shared between the Kimi website, CLI, and every API key connected to -the same account. The values in Runway therefore describe the account-wide pool, not just this Mac. -Extra Usage rows only appear when Kimi reports a Booster wallet for the account. Runway displays -the currency Kimi returns, including CNY and USD. +Kimi Code membership quota is shared between the Kimi website, the CLI, and every API key on the same account, so the values describe the account-wide pool, not just this Mac. Extra Usage rows only appear when Kimi reports a Booster wallet for the account. Runway displays the currency Kimi returns, including CNY and USD. -When Kimi reports your membership (Free, Adagio, Moderato, Allegretto, Allegro, Vivace), -Runway shows it beside the provider name. +When Kimi reports your membership (Free, Adagio, Moderato, Allegretto, Allegro, Vivace), Runway shows it beside the provider name. ## Where credentials come from @@ -27,58 +23,39 @@ Runway reads the OAuth login Kimi Code stores at: $KIMI_CODE_HOME/credentials/kimi-code.json ``` -`KIMI_CODE_HOME` defaults to `~/.kimi-code`. Runway refreshes an expiring access token the same way -as the CLI and saves the rotated access and refresh tokens back atomically with owner-only permissions. -It also uses Kimi Code's cross-process refresh lock, so the app and CLI cannot spend the same rotating -refresh token at the same time. +`KIMI_CODE_HOME` defaults to `~/.kimi-code`. Runway refreshes an expiring access token the same way the CLI does and saves the rotated access and refresh tokens back atomically with owner-only permissions. It uses Kimi Code's cross-process refresh lock, so the app and CLI cannot spend the same rotating refresh token at the same time. -The provider follows these official Kimi Code endpoint overrides when they are exported: +The provider follows these Kimi Code endpoint overrides when they are exported: - `KIMI_CODE_BASE_URL` - `KIMI_CODE_OAUTH_HOST` (or the older `KIMI_OAUTH_HOST`) -Like Kimi Code, Runway maps a non-default base URL or OAuth host to the CLI's deterministic -`kimi-code-env-.json` credential slot. A custom service can therefore never borrow the production -Kimi login. +Like Kimi Code, Runway maps a non-default base URL or OAuth host to the CLI's `kimi-code-env-.json` credential slot, so a custom service can never borrow the production Kimi login. -Bearer credentials are accepted only over HTTPS. Plain HTTP is allowed only for a loopback test -endpoint. +Bearer credentials are only sent over HTTPS. Plain HTTP is allowed only for a loopback test endpoint. ## Setup 1. Install and start [Kimi Code](https://www.kimi.com/code). 2. Run `/login` in Kimi Code and complete the browser sign-in. -3. Refresh Runway. Kimi is detected from the local OAuth credential and turns on automatically - the first time this provider is available. +3. Refresh Runway. Kimi is detected from the local OAuth credential and turns on the first time this provider is available. -An API key exported as `KIMI_API_KEY` is not used for this provider. Kimi Code's managed membership -usage command requires its OAuth login, while API-key providers can point at separate Moonshot -pay-as-you-go services with different billing. Treating either kind of key as a membership login could -show the wrong account or send the key to the wrong service, so Runway deliberately uses the -confirmed OAuth path only. +An API key exported as `KIMI_API_KEY` is not used for this provider. Kimi Code's membership usage command requires its OAuth login, and API keys can point at separate Moonshot pay-as-you-go services with different billing. Treating either kind of key as a membership login could show the wrong account or send the key to the wrong service, so Runway uses the OAuth path only. ## Under the hood -Runway mirrors the official CLI's membership flow: +Runway mirrors the CLI's membership flow: -- `GET https://api.kimi.com/coding/v1/usages` reads the quota windows, Extra Usage wallet, and - membership plan (`user.membership.level`). +- `GET https://api.kimi.com/coding/v1/usages` reads the quota windows, Extra Usage wallet, and membership plan (`user.membership.level`). - `POST https://auth.kimi.com/api/oauth/token` refreshes an expiring OAuth login. -The usage endpoint is used by Kimi Code's own `/usage` command but is not documented as a public API. -Runway accepts the same response aliases as the CLI and reports malformed responses as errors -instead of inventing zero usage. +The usage endpoint is used by Kimi Code's own `/usage` command but is not documented as a public API. Runway accepts the same response aliases as the CLI and reports malformed responses as errors instead of inventing zero usage. ## Troubleshooting -- **"Not logged in to Kimi Code"** — run Kimi Code, enter `/login`, and complete sign-in. -- **"Kimi Code session expired"** — repeat `/login`; the saved refresh token was rejected or revoked. -- **"Kimi Code credentials couldn't be read"** — check that the credential file belongs to your - macOS account and is readable. -- **"Couldn't safely refresh Kimi Code credentials"** — Kimi Code is probably rotating the same - token at this moment. Wait for it to finish, then refresh again. -- **"Kimi Code subscription usage is unavailable"** — the login works, but this account or endpoint - does not expose managed Kimi Code membership usage. -- **Kimi stays off after changing `KIMI_CODE_HOME` in your shell profile** — relaunch Runway. Shell - home and endpoint overrides are pinned for one app launch so every refresh uses one credential - identity consistently. +- **"Not logged in to Kimi Code"**: run Kimi Code, enter `/login`, and complete sign-in. +- **"Kimi Code session expired"**: repeat `/login`. The saved refresh token was rejected or revoked. +- **"Kimi Code credentials couldn't be read"**: check that the credential file belongs to your macOS account and is readable. +- **"Couldn't safely refresh Kimi Code credentials"**: Kimi Code is probably rotating the same token right now. Wait for it to finish, then refresh again. +- **"Kimi Code subscription usage is unavailable"**: the login works, but this account or endpoint does not expose membership usage. +- **Kimi stays off after changing `KIMI_CODE_HOME` in your shell profile**: relaunch Runway. Shell home and endpoint overrides are pinned for one app launch so every refresh uses one credential identity. diff --git a/docs/providers/muse.md b/docs/providers/muse.md index 5a86ae2ad..e739a588b 100644 --- a/docs/providers/muse.md +++ b/docs/providers/muse.md @@ -9,35 +9,35 @@ Tracks Muse Code subscription quota using the Meta account login from the offici | Five-Hour Usage | Usage percentage in the rolling five-hour prompt window | | Weekly Usage | Usage percentage in the weekly quota window | -When Muse reports a subscription tier (Everyday Usage, High Usage, or Power Usage), Runway shows it beside the provider name. A pay-as-you-go `META_API_KEY` is not a subscription login and is not used. Both meters start always visible, with both starred in the menu bar. +When Muse reports a subscription tier (Everyday Usage, High Usage, or Power Usage), Runway shows it beside the provider name. A pay-as-you-go `META_API_KEY` is not a subscription login and is not used. Both meters start always visible and starred in the menu bar. ## Where credentials come from -Runway never asks for a token — it reads what Muse Code already stored: +Runway reads what Muse Code already stored: -- **macOS Keychain** — service `ai.meta.dev.credentials`, account `meta`. Current `muse login` builds keep the OAuth access token there. Automatic refreshes never request its secret. After launch, the card offers a neutral **Connect** action; choose **Always Allow** to avoid a dialog on future manual reads. -- **Legacy `auth.json`** — `$MUSE_AUTH_PATH`, or `$XDG_CONFIG_HOME/muse/auth.json`, or `~/.config/muse/auth.json`. Older CLIs wrote `access_token` in this file. Current files only point at Keychain and carry no secret. +- **macOS Keychain**: service `ai.meta.dev.credentials`, account `meta`. Current `muse login` builds keep the OAuth access token there. Automatic refreshes never request its secret. After launch, the card shows a neutral **Connect** action. Choose **Always Allow** to avoid a dialog on future manual reads. +- **Legacy `auth.json`**: `$MUSE_AUTH_PATH`, or `$XDG_CONFIG_HOME/muse/auth.json`, or `~/.config/muse/auth.json`. Older CLIs wrote `access_token` in this file. Current files only point at Keychain and carry no secret. Runway never writes the Keychain item, never refreshes the OAuth login, and never saves the API key snapshot the usage endpoint returns. Muse Code owns its login. ## Setup 1. Install [Muse Code](https://dev.meta.ai) and run `muse login`. -2. Refresh Runway. Muse is detected from the local login and turns on automatically the first time this provider is available. +2. Refresh Runway. Muse is detected from the local login and turns on the first time this provider is available. Subscribe or manage the plan at [Accounts Center](https://accountscenter.meta.com/muse_code). ## Under the hood -`POST https://api.meta.ai/muse-code/key` with the Meta OAuth access token returns the subscription meters (`subs_usage.window` and `subs_usage.weekly`) plus plan metadata. The same JSON can include an API key; Runway discards it. That endpoint is used by Muse Code's own account flow and is not documented as a public API. Malformed responses and inactive subscriptions are reported as errors instead of inventing zero usage. +`POST https://api.meta.ai/muse-code/key` with the Meta OAuth access token returns the subscription meters (`subs_usage.window` and `subs_usage.weekly`) plus plan metadata. The same JSON can include an API key, which Runway discards. That endpoint is used by Muse Code's own account flow and is not documented as a public API. Malformed responses and inactive subscriptions are reported as errors instead of inventing zero usage. ## Troubleshooting -- **"Not logged in to Muse Code"** — run `muse login` and complete the Meta device-code sign-in. -- **"Muse login found in Keychain"** (a neutral key glyph / **Connect** button, not a warning) — the item is present, but automatic refreshes do not request its secret. Connect; choose **Always Allow** to avoid a dialog on future manual reads. -- **"Keychain access to the Muse login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. -- **"Couldn't read Muse credentials from Keychain"** — the keychain itself couldn't be read (usually locked). Unlock it, then refresh. -- **"Muse session expired"** — run `muse login` again; the saved access token was rejected. -- **"No active Muse Code subscription"** — the Meta login works, but this account has no Muse Code plan. Subscribe in Accounts Center, then refresh. -- **"Usage response invalid"** — the login works, but the usage payload was missing or malformed. Try again after Muse Code updates. -- **Muse stays off after changing `MUSE_AUTH_PATH` or `XDG_CONFIG_HOME` in your shell profile** — relaunch Runway. Shell home overrides are pinned for one app launch. +- **"Not logged in to Muse Code"**: run `muse login` and complete the Meta device-code sign-in. +- **"Muse login found in Keychain"** (neutral key glyph / **Connect** button): the item is present, but automatic refreshes do not request its secret. Connect, and choose **Always Allow** to avoid a dialog on future manual reads. +- **"Keychain access to the Muse login was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **"Couldn't read Muse credentials from Keychain"**: the keychain itself could not be read, usually because it is locked. Unlock it, then refresh. +- **"Muse session expired"**: run `muse login` again. The saved access token was rejected. +- **"No active Muse Code subscription"**: the Meta login works, but this account has no Muse Code plan. Subscribe in Accounts Center, then refresh. +- **"Usage response invalid"**: the login works, but the usage payload was missing or malformed. Try again after Muse Code updates. +- **Muse stays off after changing `MUSE_AUTH_PATH` or `XDG_CONFIG_HOME` in your shell profile**: relaunch Runway. Shell home overrides are pinned for one app launch. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index a62709f35..44994c849 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -1,8 +1,6 @@ # OpenCode -Tracks your OpenCode-hosted usage — the **Go** subscription and the **Zen** pay-as-you-go gateway. Go -plan windows come from OpenCode's official usage API. Spend tiles and the usage trend still come from -OpenCode's logs already on your Mac. +Tracks your OpenCode-hosted usage: the **Go** subscription and the **Zen** pay-as-you-go gateway. Go plan windows come from OpenCode's usage API. Spend tiles and the usage trend come from OpenCode's logs on your Mac. ## What it tracks @@ -11,55 +9,32 @@ OpenCode's logs already on your Mac. | Session | Go usage in the rolling 5-hour window, as a percent, with the reset countdown | | Weekly | Go usage this week, as a percent (resets Monday UTC) | | Monthly | Go usage this billing cycle, as a percent | -| Today / Yesterday / Last 30 Days | Local cost and tokens across all your OpenCode-hosted usage (Go + Zen) | -| Usage Trend | A day-by-day sparkline of tokens over the last month | +| Today / Yesterday / Last 30 Days | Local cost and tokens across all your OpenCode-hosted usage (Go and Zen) | +| Usage Trend | A day-by-day chart of tokens over the last month | When you have the Go subscription, Runway shows "Go" beside the provider name. -The Session / Weekly / Monthly meters are **account-wide** — the same percents the OpenCode dashboard -shows, including usage from other machines. If you only use the Zen pay-as-you-go gateway (no Go -subscription), the cap meters are hidden and you'll just see the spend tiles. +The Session, Weekly, and Monthly meters are account-wide, the same percents the OpenCode dashboard shows, including usage from other machines. If you only use the Zen gateway (no Go subscription), the cap meters are hidden and you see the spend tiles. ## Where credentials come from -Use OpenCode as usual. Runway reads the `opencode-go` API key from OpenCode's local data directory -(`~/.local/share/opencode/auth.json`, or `$OPENCODE_DATA_DIR` / `$XDG_DATA_HOME` if you've set them) and -sends it as a Bearer token to the usage API. There's no login prompt and no token to paste. Spend tiles -still read the local SQLite logs in that same directory. +Use OpenCode as usual. Runway reads the `opencode-go` API key from OpenCode's local data directory (`~/.local/share/opencode/auth.json`, or `$OPENCODE_DATA_DIR` / `$XDG_DATA_HOME` if set) and sends it as a Bearer token to the usage API. There is no login prompt and no token to paste. Spend tiles read the local SQLite logs in that same directory. ## The meters and spend tiles -Go meters are percents from `GET https://opencode.ai/zen/go/v1/usage` — OpenCode's own accounting, not -an estimate. Each spend tile shows cost and tokens together (`$4.08 · 1.2M tokens`), the same as Claude / -Codex / Cursor. Those dollars come straight from the per-message cost OpenCode records for its hosted -gateways on this Mac, so they can be lower than account-wide Go usage. A period with no recorded local -usage reads "No data" rather than a misleading `$0.00`. Codex usage that goes through OpenCode's -ChatGPT OAuth login is attributed to the Codex card, not these spend tiles. No log data leaves your Mac. +Go meters are percents from `GET https://opencode.ai/zen/go/v1/usage`, OpenCode's own accounting. Each spend tile shows cost and tokens together (`$4.08 · 1.2M tokens`). Those dollars come from the per-message cost OpenCode records for its hosted gateways on this Mac, so they can be lower than account-wide Go usage. A period with no recorded local usage reads "No data". Codex usage that goes through OpenCode's ChatGPT OAuth login is attributed to the Codex card, not these spend tiles. No log data leaves your Mac. ## Troubleshooting -- **No Session / Weekly / Monthly meters** — those are Go-plan windows. You'll see them when you're - logged into OpenCode Go (`opencode-go` in `auth.json`) and the key has an active subscription. - Zen-only users see the spend tiles instead. -- **"OpenCode Go key was rejected"** — the local key was not accepted. Log into OpenCode Go again so - `auth.json` is rewritten. -- **"No OpenCode Go subscription on this key"** — the key is valid but this account isn't on Go. The - spend tiles still work if you use Zen locally. -- **"Couldn't read OpenCode's auth.json"** — the file exists but is unreadable or not valid JSON. Check - its permissions, or log into OpenCode Go again to rewrite it. -- **Spend tiles show "No data"** — Runway needs OpenCode's local database at - `~/.local/share/opencode/opencode*.db`. Run an OpenCode session, then refresh. -- **"Couldn't read OpenCode's local database"** — the database (or data directory) exists but couldn't be - read this refresh. If you're on Go, the percent meters still refresh; quit OpenCode and refresh to - restore the tiles. If it persists, check the permissions on `~/.local/share/opencode`. +- **No Session / Weekly / Monthly meters**: those are Go-plan windows. You see them when you are logged into OpenCode Go (`opencode-go` in `auth.json`) and the key has an active subscription. Zen-only users see the spend tiles instead. +- **"OpenCode Go key was rejected"**: the local key was not accepted. Log into OpenCode Go again so `auth.json` is rewritten. +- **"No OpenCode Go subscription on this key"**: the key is valid but this account is not on Go. The spend tiles still work if you use Zen locally. +- **"Couldn't read OpenCode's auth.json"**: the file exists but is unreadable or not valid JSON. Check its permissions, or log into OpenCode Go again to rewrite it. +- **Spend tiles show "No data"**: Runway needs OpenCode's local database at `~/.local/share/opencode/opencode*.db`. Run an OpenCode session, then refresh. +- **"Couldn't read OpenCode's local database"**: the database or data directory exists but could not be read this refresh. If you are on Go, the percent meters still refresh. Quit OpenCode and refresh to restore the tiles. If it persists, check the permissions on `~/.local/share/opencode`. ## Under the hood -Go windows: `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` key as `Authorization: -Bearer …`. The response is `{ usage: { rolling, weekly, monthly } }`, each with `percent` and -`resetsAt`. A 401 is a rejected key; a 403 `EntitlementError` means no Go subscription. +Go windows: `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` key as `Authorization: Bearer …`. The response is `{ usage: { rolling, weekly, monthly } }`, each with `percent` and `resetsAt`. A 401 is a rejected key. A 403 `EntitlementError` means no Go subscription. -Spend tiles and trend: assistant-message `cost` and token fields from every `opencode*.db` in the data -directory (OpenCode partitions its database by release channel — stable is `opencode.db`, the preview -line is `opencode-next.db` — so all channels are unioned). Both `opencode-go` (Go) and `opencode` (Zen) -count. Read-only. +Spend tiles and trend: assistant-message `cost` and token fields from every `opencode*.db` in the data directory. OpenCode partitions its database by release channel (stable is `opencode.db`, the preview line is `opencode-next.db`), so all channels are combined. Both `opencode-go` (Go) and `opencode` (Zen) count. Read-only. diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index 6a1a3c9b6..f0410201d 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -6,55 +6,43 @@ Tracks your [OpenRouter](https://openrouter.ai) credit balance and spend from yo | Metric | Meaning | |---|---| -| Credits | Lifetime spend against the credits you've purchased (a dollar meter) | +| Credits | Lifetime spend against the credits you have purchased (a dollar meter) | | Balance | Prepaid credits remaining | | Today | Spend so far today | | This Week | Spend so far this week | | This Month | Spend so far this month | -| Key Limit | Spend in the current limit window against this key's cap — shown only when the key has one configured | +| Key Limit | Spend in the current limit window against this key's cap. Shown only when the key has one configured | Runway shows the reported tier (such as "Pay as you go" or "Free tier") beside the provider name. ## Where credentials come from -Unlike the other providers, OpenRouter has no companion app or CLI that leaves a credential on your -machine, so you supply an API key. Create one at [openrouter.ai/keys](https://openrouter.ai/keys), -then add it in **Settings → API Keys** (recommended): expand OpenRouter, paste the key, and Save. -The key is stored at `~/.config/runway/openrouter.json` and picked up on the next refresh. +OpenRouter has no companion app or CLI that leaves a credential on your machine, so you supply an API key. Create one at [openrouter.ai/keys](https://openrouter.ai/keys), then add it in **Settings → API Keys**: expand OpenRouter, paste the key, and Save. The key is stored at `~/.config/runway/openrouter.json` and picked up on the next refresh. -You can also provide the key directly (checked in this order, first match wins): +You can also provide the key directly. Checked in this order, first match wins: -1. **Config file:** `~/.config/runway/openrouter.json` — the file the Settings card writes: +1. **Config file:** `~/.config/runway/openrouter.json`, the file the Settings card writes: ```json { "apiKey": "sk-or-v1-..." } ``` - A plain-text file containing just the key, or `~/.config/openrouter/key.json`, also work. + A plain-text file containing just the key, or `~/.config/openrouter/key.json`, also works. -2. **Environment variable:** set `OPENROUTER_API_KEY` in your shell profile (e.g. `~/.zshrc` or - `~/.zprofile`). On launch the app reads your login shell's environment, so a key exported there is - picked up even when the app is started from Finder or the Dock — not just when run from a terminal. - When a key is found here, the API Keys card shows it as read-only ("From environment") with a - checkbox to override it with a saved key. +2. **Environment variable:** set `OPENROUTER_API_KEY` in your shell profile (`~/.zshrc` or `~/.zprofile`). On launch the app reads your login shell's environment, so a key exported there is picked up even when the app is started from Finder or the Dock. When a key is found here, the API Keys card shows it as read-only ("From environment") with a checkbox to override it with a saved key. -A key saved through the app overrides an environment key (the config file is checked first); removing -the saved key falls back to the environment key, or to none. +A key saved through the app overrides an environment key, because the config file is checked first. Removing the saved key falls back to the environment key, or to none. ## Troubleshooting -- **"No OpenRouter API key"** — add the key in Settings → API Keys (or the config file / env var), then refresh. -- **"API key invalid"** — the key was rejected (401/403). Check or recreate it at openrouter.ai/keys. +- **"No OpenRouter API key"**: add the key in Settings → API Keys (or the config file or env var), then refresh. +- **"API key invalid"**: the key was rejected (401/403). Check or recreate it at openrouter.ai/keys. ## Under the hood Two REST calls with a `Bearer` token against `https://openrouter.ai/api/v1`: -- `GET /credits` — account-wide `total_credits` and `total_usage`; the Credits meter and Balance come - from these. Required for a usable snapshot. -- `GET /key` — best-effort: the tier, daily/weekly/monthly spend, and an optional per-key cap - (`limit` minus `limit_remaining` for the current window). If this call fails, the balance still - renders from `/credits`. +- `GET /credits`: account-wide `total_credits` and `total_usage`. The Credits meter and Balance come from these. Required for a usable snapshot. +- `GET /key`: best-effort. The tier, daily, weekly, and monthly spend, and an optional per-key cap (`limit` minus `limit_remaining` for the current window). If this call fails, the balance still renders from `/credits`. -A period spend of `$0.00` is shown as a real, measured zero (the API reports it directly) rather than -"No data". Credit values can be up to ~60 seconds stale on OpenRouter's side. +A period spend of `$0.00` is shown as a measured zero (the API reports it directly) rather than "No data". Credit values can be up to about 60 seconds stale on OpenRouter's side. diff --git a/docs/providers/sakana.md b/docs/providers/sakana.md index 184a611ba..fcc9533ed 100644 --- a/docs/providers/sakana.md +++ b/docs/providers/sakana.md @@ -1,7 +1,6 @@ # Sakana Fugu -Tracks Fugu subscription quota from Sakana AI Console, plus local Fugu Ultra token history and -estimated API-rate value from Codex rollouts. +Tracks Fugu subscription quota from Sakana AI Console, plus local Fugu Ultra token history and estimated API-rate value from Codex rollouts. ## What it tracks @@ -14,24 +13,15 @@ estimated API-rate value from Codex rollouts. | Yesterday | Yesterday's local tokens and estimated API-rate value | | Last 30 Days | Local tokens and estimated API-rate value over the history window | -The five-hour window begins with the account's first request. Weekly usage resets every Monday at -00:00 UTC. These are account-wide subscription pools, not usage from only this Mac. +The five-hour window begins with the account's first request. Weekly usage resets every Monday at 00:00 UTC. These are account-wide subscription pools, not usage from only this Mac. -Before a subscription window has any usage, Sakana Console returns that quota row as explicitly -empty. Runway treats that state as 0% used and keeps showing both meters. A missing quota field or -malformed value still produces an unsupported-response error so a real console format change is not -mistaken for zero usage. +Before a subscription window has any usage, Sakana Console returns that quota row as empty. Runway treats that as 0% used and keeps showing both meters. A missing quota field or malformed value still produces an unsupported-response error, so a real console format change is not mistaken for zero usage. -The graph and spend rows are different: they come from local Codex logs and include only usage saved -on this Mac. If private iCloud sync is enabled, Runway can combine that machine-local history with -history from your other Macs without double-counting the account-wide subscription meters. +The graph and spend rows come from local Codex logs and include only usage saved on this Mac. With iCloud sync on, Runway combines that machine-local history with history from your other Macs without double-counting the account-wide subscription meters. ## Local Fugu history -Runway finds Sakana-configured Codex homes from `CODEX_HOME`, `~/.codex*`, and direct directories -under `~/.config`. This includes launcher-specific homes such as `~/.codex-fugu`. It parses the same -`sessions/` and `archived_sessions/` rollout files as the Codex provider, filters the events to Fugu, -and avoids copied-session and subagent-replay double counting. +Runway finds Sakana-configured Codex homes from `CODEX_HOME`, `~/.codex*`, and direct directories under `~/.config`. This includes launcher-specific homes such as `~/.codex-fugu`. It parses the same `sessions/` and `archived_sessions/` rollout files as the Codex provider, filters the events to Fugu, and avoids copied-session and subagent-replay double counting. Only models with a fixed published price are included: @@ -40,75 +30,46 @@ Only models with a fixed published price are included: | Fugu Ultra v1.0 / v1.1 | $5 / 1M | $0.50 / 1M | $30 / 1M | | Fugu Cyber v1.0 | $6 / 1M | $0.60 / 1M | $36 / 1M | -For requests above 272,000 input tokens, the whole request uses the published long-context rates: -Ultra uses $10 input, $1 cached input, and $45 output per million; Cyber uses $12, $1.20, and $54. -Plain `fugu` is deliberately left unpriced because its charge depends on the routed underlying model. +For requests above 272,000 input tokens, the whole request uses the published long-context rates: Ultra uses $10 input, $1 cached input, and $45 output per million; Cyber uses $12, $1.20, and $54. Plain `fugu` is left unpriced because its charge depends on the routed underlying model. -These dollars are estimates of API-rate value, not an invoice or an extra subscription charge. Codex -saves input, cached-input, output, and total counts, but not Sakana's separate orchestration-detail -fields. The graph and estimate can therefore undercount orchestration tokens. Runway never invents -the missing fields or adds reasoning tokens a second time. +These dollars are estimates of API-rate value, not an invoice or an extra subscription charge. Codex saves input, cached-input, output, and total counts, but not Sakana's separate orchestration-detail fields, so the graph and estimate can undercount orchestration tokens. Runway never invents the missing fields or adds reasoning tokens a second time. ## Where credentials come from -Runway looks for a signed-in `console.sakana.ai` session in Chrome, Arc, Brave, and Microsoft Edge -profiles. It reads the browser's cookie database in read-only mode, decrypts the Sakana session in -memory with that browser's Safe Storage key, and sends the cookie only to Sakana Console. Runway -does not copy the cookie into its own configuration, refresh it, or change the browser database. +Runway looks for a signed-in `console.sakana.ai` session in Chrome, Arc, Brave, and Microsoft Edge profiles. It reads the browser's cookie database read-only, decrypts the Sakana session in memory with that browser's Safe Storage key, and sends the cookie only to Sakana Console. Runway does not copy the cookie into its own configuration, refresh it, or change the browser database. -After launch, the card offers a neutral **Connect** action to load the browser's Safe Storage key. -That read can show a macOS Keychain prompt; choosing **Always Allow** avoids the dialog on future -manual reads. Runway caches the derived key for the rest of the process, so scheduled refreshes do -not request the Keychain secret. If you deny the request, the browser session stays untouched and -the provider shows a permission warning. +After launch, the card shows a neutral **Connect** action to load the browser's Safe Storage key. That read can show a macOS Keychain prompt. Choose **Always Allow** to avoid the dialog on future manual reads. Runway caches the derived key for the rest of the process, so scheduled refreshes do not request the Keychain secret. If you deny the request, the browser session stays untouched and the provider shows a permission warning. -Safari is not currently supported because it uses a different cookie store and security model. +Safari is not supported because it uses a different cookie store and security model. ## Setup 1. Open [Sakana AI Console](https://console.sakana.ai/) in Chrome, Arc, Brave, or Microsoft Edge. 2. Sign in to the Sakana account that owns the Fugu subscription. -3. If you want local spend estimates and the usage graph, use Fugu through a Sakana-configured - Codex home, such as the official `codex-fugu` launcher. +3. For local spend estimates and the usage graph, use Fugu through a Sakana-configured Codex home, such as the official `codex-fugu` launcher. 4. Refresh Runway and approve the browser Safe Storage Keychain request if macOS shows one. -Runway detects either the local Sakana cookie or a Sakana Codex home without contacting the -network during first-run and new-provider detection. +Runway detects either the local Sakana cookie or a Sakana Codex home without contacting the network during first-run and new-provider detection. ## Why the API key is not used -`SAKANA_API_KEY` authenticates model requests at `api.sakana.ai`, but Sakana does not expose the -five-hour or weekly subscription pools, or account-wide request history, through a documented API-key -endpoint. A model response contains only that request's token counts, not the remaining account quota. -Direct use of responses also requires Runway to proxy every future request. +`SAKANA_API_KEY` authenticates model requests at `api.sakana.ai`, but Sakana does not expose the five-hour or weekly subscription pools, or account-wide request history, through a documented API-key endpoint. A model response contains only that request's token counts, not the remaining quota. Using responses directly would also require Runway to proxy every request. -The provider uses the signed-in console session for subscription usage and reads the resulting local -Codex records for history. Your `SAKANA_API_KEY` remains available to Codex and other tools that make -model calls; Runway neither reads nor sends it. +The provider uses the signed-in console session for subscription usage and reads the local Codex records for history. Your `SAKANA_API_KEY` stays available to Codex and other tools. Runway neither reads nor sends it. ## Under the hood -- `GET https://console.sakana.ai/api/auth/session` verifies that the borrowed browser session is - current. -- `GET https://console.sakana.ai/billing` reads the five-hour and weekly values rendered by Sakana - Console. +- `GET https://console.sakana.ai/api/auth/session` verifies that the borrowed browser session is current. +- `GET https://console.sakana.ai/billing` reads the five-hour and weekly values rendered by Sakana Console. -The billing data is embedded in the console's authenticated page payload rather than exposed by a -public quota API. Runway validates the known shape strictly and reports a decoding error if Sakana -changes it, instead of silently displaying zero. Local history scanning makes no network requests. +The billing data is embedded in the console's authenticated page payload rather than exposed by a public quota API. Runway validates the known shape strictly and reports a decoding error if Sakana changes it, instead of displaying zero. Local history scanning makes no network requests. ## Troubleshooting -- **"Sign in to Sakana AI Console"** — sign in through a supported Chromium browser, then refresh. -- **"Sakana browser session found"** (a neutral key glyph / **Connect** button, not a warning) — the - Safe Storage key hasn't been loaded this app session. Connect and approve the macOS Keychain - prompt; choose **Always Allow** to avoid a dialog on future manual reads. -- **"Keychain access to your browser's Safe Storage key was declined"** — a manual read was denied. - Refresh and choose **Always Allow** when macOS asks. -- **"The Sakana browser session expired"** — sign out and back in at Sakana AI Console, then refresh. -- **"The Sakana browser session couldn't be decoded"** — update or restart the browser, sign in again, - and retry. A change to the browser's cookie encryption can cause this. -- **"Unsupported billing response"** — Sakana changed its private console page format. Update - Runway; your browser login and API key are not modified. -- **Graph or spend rows show "No data"** — use Fugu through a detected Codex home. Plain `fugu` cannot - be assigned a fixed estimate; use Fugu Ultra or Cyber for priced history. +- **"Sign in to Sakana AI Console"**: sign in through a supported Chromium browser, then refresh. +- **"Sakana browser session found"** (neutral key glyph / **Connect** button): the Safe Storage key has not been loaded this session. Connect and approve the macOS Keychain prompt. Choose **Always Allow** to avoid a dialog on future manual reads. +- **"Keychain access to your browser's Safe Storage key was declined"**: a manual read was denied. Refresh and choose **Always Allow** when macOS asks. +- **"The Sakana browser session expired"**: sign out and back in at Sakana AI Console, then refresh. +- **"The Sakana browser session couldn't be decoded"**: update or restart the browser, sign in again, and retry. A change to the browser's cookie encryption can cause this. +- **"Unsupported billing response"**: Sakana changed its private console page format. Update Runway. Your browser login and API key are not modified. +- **Graph or spend rows show "No data"**: use Fugu through a detected Codex home. Plain `fugu` cannot be priced. Use Fugu Ultra or Cyber for priced history. diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 4cc08b8a5..dbb2e6db2 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -1,6 +1,6 @@ # Z.ai -Tracks [Z.ai](https://z.ai) (Zhipu AI) GLM Coding Plan usage quotas for coding subscriptions. +Tracks [Z.ai](https://z.ai) (Zhipu AI) GLM Coding Plan usage quotas. ## What it tracks @@ -8,56 +8,44 @@ Tracks [Z.ai](https://z.ai) (Zhipu AI) GLM Coding Plan usage quotas for coding s |---|---| | Session | 5-hour rolling window token usage (percentage) | | Weekly | 7-day rolling window token usage (percentage) | -| Web Searches | Monthly web-search / web-reader / Zread calls (used / limit) | +| Web Searches | Monthly web-search, web-reader, and Zread calls (used / limit) | When Z.ai reports your plan name, Runway shows it beside the provider name. ## Where credentials come from -Z.ai has no companion CLI/app that Runway can reuse a credential from, so you supply an API key. -Runway reads it from the first place it finds one, in this order: +Z.ai has no companion CLI or app that Runway can reuse a credential from, so you supply an API key. Runway reads it from the first place it finds one, in this order: -1. `~/.config/runway/zai.json` — `{"apiKey":"…"}` (the file Settings writes to) +1. `~/.config/runway/zai.json`: `{"apiKey":"…"}` (the file Settings writes to) 2. `~/.config/zai/key.json` 3. The `ZAI_API_KEY` environment variable -4. The `GLM_API_KEY` environment variable (the legacy Zhipu name, still accepted) +4. The `GLM_API_KEY` environment variable (the legacy Zhipu name) -You can also add and rotate the key from **Settings → API Keys** without touching a file. Either -way, nothing leaves your Mac except the same API calls Z.ai's own subscription UI makes. +You can also add and rotate the key from **Settings → API Keys**. Either way, nothing leaves your Mac except the same API calls Z.ai's own subscription UI makes. ## Setup -1. [Subscribe to a GLM Coding plan](https://z.ai/subscribe) and get your API key from the - [Z.ai console](https://z.ai/manage-apikey/apikey-list). -2. Add the key to Runway via **Settings → API Keys**, **or** export it: +1. [Subscribe to a GLM Coding plan](https://z.ai/subscribe) and get your API key from the [Z.ai console](https://z.ai/manage-apikey/apikey-list). +2. Add the key to Runway via **Settings → API Keys**, or export it: ```bash export ZAI_API_KEY="YOUR_API_KEY" ``` -3. Z.ai appears on the dashboard and (after you star a metric) the menu bar on the next refresh. +3. Z.ai appears on the dashboard, and in the menu bar after you star a metric, on the next refresh. ## Under the hood -Two undocumented internal endpoints Z.ai's own subscription UI uses (stable in practice): +Two undocumented internal endpoints Z.ai's own subscription UI uses: -- `GET https://api.z.ai/api/biz/subscription/list` — plan name (best-effort; a failure here doesn't - blank the meters). -- `GET https://api.z.ai/api/monitor/usage/quota/limit` — the quota meters. +- `GET https://api.z.ai/api/biz/subscription/list`: plan name (best-effort; a failure here does not blank the meters). +- `GET https://api.z.ai/api/monitor/usage/quota/limit`: the quota meters. -The quota response carries a `limits` array. Each `CREDIT_LIMIT` entry (called `TOKENS_LIMIT` in -older responses) is a percentage quota window; its window length decides which meter it feeds -(sub-daily → Session, multi-day → Weekly), while a `TIME_LIMIT` entry is the monthly web-search -count. Reset times come back as epoch milliseconds. Missing required usage values are reported as -an invalid response instead of being shown as zero. +The quota response carries a `limits` array. Each `CREDIT_LIMIT` entry (called `TOKENS_LIMIT` in older responses) is a percentage quota window. Its window length decides which meter it feeds (sub-daily → Session, multi-day → Weekly). A `TIME_LIMIT` entry is the monthly web-search count. Reset times come back as epoch milliseconds. Missing required usage values are reported as an invalid response instead of shown as zero. ## Troubleshooting -- **"No Z.ai API key"** — add a key in Settings → API Keys, or export `ZAI_API_KEY`. -- **"Z.ai API key invalid"** — the key was rejected (401/403). Regenerate it in the - [Z.ai console](https://z.ai/manage-apikey/apikey-list). -- **"No active GLM Coding Plan"** (amber notice by the name) — the key is valid, but the account has no - GLM Coding Plan, so there's nothing to meter. Subscribe at [z.ai/subscribe](https://z.ai/subscribe); - usage appears once your plan is active. -- **Meters show "No usage data"** — you have a plan, but the quota endpoint returned no usable limits - yet. Check your [plan](https://z.ai/manage-apikey/coding-plan/personal/my-plan). +- **"No Z.ai API key"**: add a key in Settings → API Keys, or export `ZAI_API_KEY`. +- **"Z.ai API key invalid"**: the key was rejected (401/403). Regenerate it in the [Z.ai console](https://z.ai/manage-apikey/apikey-list). +- **"No active GLM Coding Plan"** (amber notice by the name): the key is valid, but the account has no GLM Coding Plan. Subscribe at [z.ai/subscribe](https://z.ai/subscribe). Usage appears once your plan is active. +- **Meters show "No usage data"**: you have a plan, but the quota endpoint returned no usable limits yet. Check your [plan](https://z.ai/manage-apikey/coding-plan/personal/my-plan). diff --git a/docs/proxy.md b/docs/proxy.md index 990c090dd..2ba2ca9cd 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -1,11 +1,11 @@ # Proxy -Runway can route all provider requests through an optional proxy. +Runway can route all provider requests through a proxy. - Supported: `socks5://`, `http://`, `https://` - Config file: `~/.runway/config.json` - Default: off -- UI: none — file only +- No UI. File only. ## Config file @@ -18,7 +18,7 @@ Runway can route all provider requests through an optional proxy. } ``` -Authenticated proxies put credentials in the URL: +For an authenticated proxy, put the credentials in the URL: ```json { @@ -33,9 +33,9 @@ When the URL has no port, the scheme's default applies (socks5 → 1080, http ## Behavior -- Runway reads the config once at launch — **after you change the file, restart Runway**. -- `localhost`, `127.0.0.1`, and `::1` always bypass the proxy (the [local HTTP API](local-http-api.md) is unaffected). -- A missing, disabled, invalid, or unreadable config simply leaves proxying off. +- Runway reads the config once at launch. Restart Runway after changing the file. +- `localhost`, `127.0.0.1`, and `::1` always bypass the proxy, so the [local HTTP API](local-http-api.md) is unaffected. +- A missing, disabled, invalid, or unreadable config leaves proxying off. ## Scope diff --git a/docs/refreshing.md b/docs/refreshing.md index 09e17b7e3..45adefbc6 100644 --- a/docs/refreshing.md +++ b/docs/refreshing.md @@ -2,67 +2,36 @@ ## When data updates -- All enabled providers refresh together: once at launch, then every 5 minutes (a fixed cadence — there's no setting for it). Opening the popover does not start a second automatic pass. Providers fetch in parallel, so fast cards update without waiting for a slow one. Results that land close together coalesce briefly (a fraction of a second), so a burst publishes as one update. The batch itself still finishes only after every provider returns; notifications, history sync, and the next five-minute wait begin after that point. -- Turning a provider on (yourself in Customize, or automatically by first-launch/new-provider detection) fetches it promptly instead of waiting out the interval — even when the change lands in the middle of a refresh that's already running. -- The dashboard footer shows a compact countdown to the next update (like `5m` or `45s`). **Clicking it (or pressing ⌘R while that footer is present)** refreshes immediately, skipping the cache. If several providers need Keychain approval, their dialogs appear one at a time during that same refresh; Runway never opens two approval dialogs together. -- Automatic refreshes never show Keychain UI. Once you've approved Runway for a login with - **Always Allow**, background refreshes read it silently — including right after a relaunch, so an - approved login needs no clicks at all. A login macOS would have to ask about (a new item, or one - you haven't approved yet) is never asked about in the background: its card shows the neutral key - glyph / **Connect** button instead, and the single dialog appears only when you click it. Under - the hood, background reads run with Keychain UI explicitly suppressed, so a read that would have - prompted fails silently into the Connect state rather than opening an unattended dialog. That - waiting state is not an error — nothing is broken and nothing was denied — so it never shows a - warning triangle. If the app that owns a credential resets its item's sharing rules on a token - rotation (blocking Runway's direct read even though your Always Allow is intact), Runway recovers - by reading through Apple's own security helper — but only after proving from the item's rules - that the helper needs no dialog, so this too can never prompt. Runway also only shows an approval - dialog when the approval can actually last: - a build whose code signature can't hold one (an unsigned developer build) skips the dialog and - stays on the neutral Connect state, with the reason in the log. -- The one-shot `runway` command reuses this same persisted cache for five minutes, refreshes missing or stale entries without starting the app, and exits. `runway --force` runs the same forced provider refresh as ⌘R regardless of cache age — with one difference: it never opens a Keychain approval dialog, and it can't reuse the app's approval either (the command is a separately signed binary). Providers whose credentials live only in a protected Keychain item are read by the app and reach the command through the shared snapshot; see [CLI](cli.md). -- While a provider is fetching, a small spinner appears next to its name (and the footer countdown becomes one), so you can tell a refresh is in flight rather than wondering if the numbers are stale. -- With [iCloud Sync](icloud-sync.md) on, a refresh batch publishes this device's one sync record after - the whole batch finishes. Manual provider refreshes write after that provider finishes, and adjacent - changes are debounced into one write. +- All enabled providers refresh together: once at launch, then every 5 minutes. There is no setting for the interval. Opening the popover does not start another pass. Providers fetch in parallel, so fast cards update without waiting for a slow one. Results that land close together are published as one update. The batch finishes only after every provider returns. Notifications, history sync, and the next five-minute wait start after that. +- Turning a provider on (in Customize, or through first-launch or new-provider detection) fetches it right away instead of waiting for the interval, even in the middle of a running refresh. +- The dashboard footer shows a countdown to the next update (like `5m` or `45s`). Click it, or press ⌘R, to refresh now and skip the cache. If several providers need Keychain approval, their dialogs appear one at a time. Runway never opens two approval dialogs together. +- Automatic refreshes never show Keychain UI. Once you have approved Runway for a login with **Always Allow**, background refreshes read it silently, including right after a relaunch. A login macOS would have to ask about (a new item, or one you have not approved yet) is never asked about in the background. Its card shows the neutral key glyph or **Connect** button, and the dialog appears only when you click it. Background reads run with Keychain UI suppressed, so a read that would have prompted lands in the Connect state instead. That state is not an error and shows no warning triangle. If the app that owns a credential resets its item's sharing rules on a token rotation and blocks Runway's direct read even though your Always Allow is intact, Runway reads through Apple's security helper instead, but only after confirming from the item's rules that the helper needs no dialog. Runway also only shows an approval dialog when the approval can last. An unsigned developer build skips the dialog, stays on the Connect state, and logs the reason. +- The one-shot `runway` command reuses the same cache for five minutes, refreshes missing or stale entries without starting the app, and exits. `runway --force` runs the same forced refresh as ⌘R regardless of cache age, but it never opens a Keychain approval dialog and cannot reuse the app's approval, because the command is a separately signed binary. Providers whose credentials live only in a protected Keychain item reach the command through the snapshot the app writes. See [CLI](cli.md). +- While a provider is fetching, a spinner appears next to its name, and the footer countdown becomes one too. +- With [iCloud Sync](icloud-sync.md) on, a refresh batch publishes this device's sync record after the whole batch finishes. Manual provider refreshes write after that provider finishes, and nearby changes are combined into one write. ## Caching -Snapshots are cached on disk and load instantly at launch, so you see your last-known values immediately instead of placeholders — even before the first fetch finishes. +Snapshots are cached on disk and load at launch, so you see your last-known values right away, before the first fetch finishes. -Claude and Codex cache entries also remember which account produced them. If you swap the account -signed in at the provider's default home between launches, the previous account's cached values are -discarded at the next launch (the card starts empty and fills on its first fetch) instead of briefly -showing the old account's limits and plan under the new login. +Claude and Codex cache entries also remember which account produced them. If you swap the account signed in at the provider's default home between launches, the previous account's cached values are dropped at the next launch. The card starts empty and fills on its first fetch instead of showing the old account's limits and plan under the new login. -A cached value only counts as *fresh* (skip-a-refresh fresh) when it was fetched **during the current running session**. So a value cached in an earlier session always re-fetches on the first pass after launch — you still see it instantly, but the app never waits out the old interval before getting live numbers. This matters after an update: a new app version refreshes right away instead of showing the previous version's data until its interval lapses. Within a session, a freshly fetched value then counts as fresh for one refresh interval before the next pass re-fetches it. +A cached value only counts as fresh when it was fetched during the current running session. A value cached in an earlier session always re-fetches on the first pass after launch. You still see it instantly, but the app never waits out the old interval before getting live numbers. This matters after an update: a new version refreshes right away instead of showing the previous version's data. Within a session, a freshly fetched value counts as fresh for one refresh interval. -Claude, Codex, and pi spend history has a separate local-log parse cache under -`~/Library/Application Support/Runway/log-scan-cache/`. It stores parsed usage events before Runway -applies model-rate estimates, so pricing updates take effect without re-reading unchanged JSONL. On -relaunch, an entry is reused only when its path, size, modification time, and parser version still match. -A Claude or Codex session log that only *grew* since its last parse doesn't re-read from the start. -The cache remembers how far it parsed, plus a fingerprint of the bytes just before that point, so it -still detects a rewritten file and re-parses it in full. The scanner reads only the newly appended lines. -This keeps refreshes cheap while a long agent session appends to a very large log file. -Same-home cards share parsed data, and changing one source file rewrites only that file's record. Old files -leave the cache as the history window advances, and identities unused for 35 days are removed. App writes -are debounced until after refresh; the one-shot CLI drains pending writes before it exits. +Claude, Codex, and pi spend history has a separate parse cache under `~/Library/Application Support/Runway/log-scan-cache/`. It stores parsed usage events before pricing, so pricing updates take effect without re-reading unchanged JSONL. On relaunch, an entry is reused only when its path, size, modification time, and parser version still match. A session log that only grew since its last parse is not re-read from the start. The cache remembers how far it parsed plus a fingerprint of the bytes just before that point, so it still detects a rewritten file and re-parses it in full. Same-home cards share parsed data, and changing one source file rewrites only that file's record. Old files leave the cache as the history window advances, and identities unused for 35 days are removed. App writes are debounced until after refresh. The CLI flushes pending writes before it exits. ## When a fetch fails -A failed refresh **never wipes your data**: the last good values stay on screen, and a small warning triangle appears at the right edge of the provider's header — hover it for the error message (e.g. "Not logged in"). **Click the triangle to refresh that provider**, which is usually what clears the problem — a denied Keychain approval, or a token you just renewed in your terminal. Like every refresh you ask for yourself, that click may show a macOS permission prompt; background refreshes never do. While the refresh runs, the triangle gives way to the header's spinner. One kind of notice stays a plain symbol with no click: the ones that ask you to wait, like Claude's "Updates blocked by Anthropic" during a rate limit, where refreshing again only makes the block last longer. The error clears on the next successful refresh. +A failed refresh never wipes your data. The last good values stay on screen, and a warning triangle appears at the right edge of the provider's header. Hover it for the error message (for example "Not logged in"). Click the triangle to refresh that provider, which usually clears the problem (a denied Keychain approval, or a token you just renewed in your terminal). Like every manual refresh, that click may show a macOS permission prompt. Background refreshes never do. While the refresh runs, the triangle gives way to the header's spinner. Notices that ask you to wait, like Claude's "Updates blocked by Anthropic" during a rate limit, stay a plain symbol with no click, because refreshing again only makes the block last longer. The error clears on the next successful refresh. -A Keychain login that needs your one-time approval is deliberately **not** one of these failures. It shows a muted key glyph in the header (or a **Connect** button when the card has no data yet) instead of the warning triangle — same click, neutral styling — because nothing needs fixing: the login just needs one approval, granted through that click. The warning triangle appears for Keychain only when something actually went wrong: you declined the approval dialog (the message then says access was declined and to choose Always Allow), or the keychain itself couldn't be read (locked, or securityd failing). +A Keychain login that needs your one-time approval is not one of these failures. It shows a muted key glyph in the header, or a **Connect** button when the card has no data yet, because nothing needs fixing. The warning triangle appears for Keychain only when something went wrong: you declined the approval dialog (the message says access was declined and to choose Always Allow), or the keychain itself could not be read (locked, or securityd failing). -When a provider stops responding, Runway cuts it off after a per-provider ceiling (2.5 minutes for most; providers with legitimately slower flows, like Copilot's multi-org billing probe, allow more). So only genuinely dead work gets cut. The attempt counts as a failed refresh — same warning triangle, message "Refresh timed out after 150s" — instead of leaving the refresh spinner running forever. Like any failure, the provider backs off briefly before the next attempt, and a new attempt never overlaps a timed-out one that is still winding down. +When a provider stops responding, Runway cuts it off after a per-provider ceiling (2.5 minutes for most; slower flows like Copilot's multi-org billing probe get more). The attempt counts as a failed refresh with the message "Refresh timed out after 150s". The provider backs off briefly before the next attempt, and a new attempt never overlaps a timed-out one that is still winding down. -The last good normalized history is preserved too, so a temporary provider failure—or a successful -limit refresh whose local log scan is temporarily unavailable—does not remove this Mac's previous -contribution from an iCloud-combined spend total. +The last good normalized history is preserved too, so a temporary provider failure, or a successful limit refresh whose local log scan is temporarily unavailable, does not remove this Mac's previous contribution from an iCloud-combined spend total. -Rows that have never had data show "No data" rather than made-up numbers. +Rows that have never had data show "No data". ## Stale data -A failed refresh keeps the last good values on screen, so those values can persist while refreshes keep failing. Without a marker, a plan or limit that changed on the provider's side keeps showing the old figures indefinitely. To make that obvious, a small **"Outdated"** tag appears next to the provider's name once its data is more than a couple of refresh cycles old (about ten minutes); hover it for the precise age ("Last updated 3h ago"). The tag stays short so it never crowds a long plan name. When you see it, the numbers below are from that earlier time, not live — usually because the provider is failing to refresh (check the warning triangle) or the Mac was asleep. A successful refresh clears it. +Because a failed refresh keeps the last good values on screen, those values can persist while refreshes keep failing. An **Outdated** tag appears next to the provider's name once its data is more than about ten minutes old. Hover it for the exact age ("Last updated 3h ago"). When you see it, the numbers below are from that earlier time, usually because the provider is failing to refresh (check the warning triangle) or the Mac was asleep. A successful refresh clears it. diff --git a/docs/releasing.md b/docs/releasing.md index 0af56dfee..0dc77b0d1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,38 +1,50 @@ # Releasing -Releases are automated: when you push a stable tag such as `v0.7.1` on `main`, the pipeline tests, builds, signs, notarizes, and publishes a new version with its SHA-256 checksum. Prerelease suffixes are rejected. The macOS pipeline lives in [.github/workflows/release.yml](../.github/workflows/release.yml), which calls the separate [iOS TestFlight pipeline](../.github/workflows/release-ios.yml) in parallel. The step-by-step is in the `release-swift` skill. +Releases are automated. When you push a stable tag such as `v0.7.1` on `main`, the pipeline tests, builds, signs, notarizes, and publishes a new version with its SHA-256 checksum. Prerelease suffixes are rejected. The macOS pipeline is [.github/workflows/release.yml](../.github/workflows/release.yml), which calls the [iOS TestFlight pipeline](../.github/workflows/release-ios.yml) in parallel. The step-by-step is in the `release-swift` skill. -Release tags are owner-managed — see [CONTRIBUTING.md](../CONTRIBUTING.md). Everything below is one-time setup for the maintainer's fork, not something contributors need. +Release tags are owner-managed. See [CONTRIBUTING.md](../CONTRIBUTING.md). Everything below is one-time setup for the maintainer's fork, not something contributors need. ## Release setup (one-time) The release workflow needs these repository secrets (Settings → Secrets and variables → Actions): -| Secret | What it is | -| ---------------------------- | --------------------------------------------------------------------- | -| `DEVELOPER_ID_CERTIFICATE_BASE64` | base64 of the Runway Developer ID Application `.p12` | -| `DEVELOPER_ID_CERTIFICATE_PASSWORD` | the password set when exporting that `.p12` | -| `APPLE_NOTARY_PRIVATE_KEY_BASE64` | base64 of an App Store Connect API private key (`.p8`) | -| `APPLE_NOTARY_KEY_ID` | the App Store Connect API key ID | -| `APPLE_NOTARY_ISSUER_ID` | the App Store Connect API issuer ID | +| Secret | What it is | +| --- | --- | +| `DEVELOPER_ID_CERTIFICATE_BASE64` | base64 of the Runway Developer ID Application `.p12` | +| `DEVELOPER_ID_CERTIFICATE_PASSWORD` | the password set when exporting that `.p12` | +| `APPLE_NOTARY_PRIVATE_KEY_BASE64` | base64 of an App Store Connect API private key (`.p8`) | +| `APPLE_NOTARY_KEY_ID` | the App Store Connect API key ID | +| `APPLE_NOTARY_ISSUER_ID` | the App Store Connect API issuer ID | | `APPLE_DEVELOPER_ID_ICLOUD_PROFILE` | base64 Developer ID provisioning profile for the production iCloud container | -| `SPARKLE_PUBLIC_KEY` | base64 EdDSA public key, baked into the build as `SUPublicEDKey` | -| `SPARKLE_PRIVATE_KEY` | base64 EdDSA private key used to sign the DMG | +| `SPARKLE_PUBLIC_KEY` | base64 EdDSA public key, baked into the build as `SUPublicEDKey` | +| `SPARKLE_PRIVATE_KEY` | base64 EdDSA private key used to sign the DMG | | `APPLE_DISTRIBUTION_CERTIFICATE_BASE64` | base64 of the Apple Distribution `.p12` that signs the iOS app | -| `APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD` | the password set when exporting that `.p12` | -| `APPLE_IOS_APP_STORE_PROFILE` | base64 App Store provisioning profile for the iOS app | +| `APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD` | the password set when exporting that `.p12` | +| `APPLE_IOS_APP_STORE_PROFILE` | base64 App Store provisioning profile for the iOS app | | `APPLE_IOS_WIDGET_APP_STORE_PROFILE` | base64 App Store provisioning profile for the iOS widget extension | -Export the NextByte Developer ID Application cert (with its private key) from Keychain Access as a `.p12`, then `base64 -i DeveloperID.p12 | pbcopy`. Create an App Store Connect API key with the **App Manager** role (it both notarizes the Mac app and cloud-signs/uploads the iOS app), download its `.p8` file once, and base64-encode it the same way. The certificate, API key, and iCloud profile must all belong to NextByte team `8KZBNZJBAX`. Generate the Sparkle EdDSA key pair once with Sparkle's `generate_keys` tool; the public and private values must be a matching pair or signing is silently skipped. +### macOS signing and notarization -The iOS TestFlight jobs sign manually with the `APPLE_DISTRIBUTION_*` cert and the two `APPLE_IOS_*_PROFILE` secrets (one App Store profile per bundle ID: the app `com.mattstallone.runway.mobile` and the widget extension `com.mattstallone.runway.mobile.widgets`) and reuse the three `APPLE_NOTARY_*` secrets for the upload and TestFlight API calls. Xcode cloud signing is deliberately not used: an App Manager API key cannot access cloud-managed distribution certificates ("Cloud signing permission error"). You can create the Apple Distribution certificate and App Store profiles through the App Store Connect API with the App Manager key: generate an RSA-2048 CSR, `POST /v1/certificates` with type `DISTRIBUTION`, then `POST /v1/profiles` with type `IOS_APP_STORE` referencing the bundle ID and certificate — once per bundle ID. Package the cert + private key as a `.p12` **with OpenSSL 3's `-legacy` flag** (its modern defaults produce a `.p12` macOS `security import` rejects with "MAC verification failed") and include the WWDR G3 intermediate. Store the `.p12`, its password, and the profiles in 1Password alongside the other signing material. +Export the Developer ID Application cert (with its private key) from Keychain Access as a `.p12`, then `base64 -i DeveloperID.p12 | pbcopy`. Create an App Store Connect API key with the **App Manager** role (it notarizes the Mac app and uploads the iOS app), download its `.p8` once, and base64-encode it the same way. The certificate, API key, and iCloud profile must all belong to the same team (`8KZBNZJBAX`). Generate the Sparkle EdDSA key pair once with Sparkle's `generate_keys` tool. The public and private values must be a matching pair, or signing is silently skipped. -One-time App Store Connect setup beyond the secrets: create the app record (My Apps → New App, iOS, bundle ID `com.mattstallone.runway.mobile`, any unique SKU), and add an internal TestFlight tester group with **automatic distribution** so every uploaded build reaches internal testers without a manual step. The App ID must already carry the CloudKit capability with both Runway containers (see [iOS app](ios-app.md)). +### iOS signing -The iOS jobs only run when the release needs them: the workflow's iOS Gate job skips Mac-only releases (no iOS-relevant changes since the last build the external testers actually received) unless that build is nearing its 90-day expiry — see [iOS app](ios-app.md#releasing-testflight). For external testers, the TestFlight External job submits each shipped release for Beta App Review and testers receive it when Apple approves. The job runs in the **iOS TestFlight** GitHub environment, so the repository homepage's Deployments panel always shows the public join link (https://testflight.apple.com/join/uA4aHUEx) next to the Update Feed deployment. Its one-time setup has two steps. First, create an external group named `External` under TestFlight → External Testing; add testers by email or enable a public link (the workflow's `TESTFLIGHT_EXTERNAL_GROUPS` env lists the group names it ships to). Second, fill in the app's TestFlight **Test Information**: the beta app description, the feedback email, and the review contact/sign-in details the first submission asks for. The app needs no demo account: it reads the tester's own iCloud data, so mark it as not requiring sign-in and say so in the review notes. +The iOS TestFlight jobs sign manually with the `APPLE_DISTRIBUTION_*` cert and the two `APPLE_IOS_*_PROFILE` secrets (one App Store profile per bundle ID: the app `com.mattstallone.runway.mobile` and the widget extension `com.mattstallone.runway.mobile.widgets`), and reuse the three `APPLE_NOTARY_*` secrets for the upload and TestFlight API calls. Xcode cloud signing is not used, because an App Manager API key cannot access cloud-managed distribution certificates ("Cloud signing permission error"). -For iCloud Sync, store the original development and Developer ID provisioning profiles in 1Password as secure documents. Install the development profile on each registered Mac; base64-encode the Developer ID profile and store it only in the `APPLE_DEVELOPER_ID_ICLOUD_PROFILE` Actions secret. See [iCloud Sync](icloud-sync.md#development-and-release-setup) for the container identifiers, build command, and file-inspection command. +You can create the Apple Distribution certificate and App Store profiles through the App Store Connect API with the App Manager key: generate an RSA-2048 CSR, `POST /v1/certificates` with type `DISTRIBUTION`, then `POST /v1/profiles` with type `IOS_APP_STORE` referencing the bundle ID and certificate, once per bundle ID. Package the cert and private key as a `.p12` **with OpenSSL 3's `-legacy` flag** (the modern defaults produce a `.p12` that macOS `security import` rejects with "MAC verification failed") and include the WWDR G3 intermediate. Store the `.p12`, its password, and the profiles in 1Password with the other signing material. -The repository must be public (Sparkle fetches the DMG and appcast anonymously), and the update feed is served through GitHub Pages. Set Settings → Pages → Build and deployment → Source to **GitHub Actions**; the publishing workflows create and maintain the `update-feed` branch and deploy through the **Update Feed** environment. The landing page at the same Pages origin ships the same way: `website/` on `main` is published to `update-feed` by `.github/workflows/landing-page.yml` on merge, alongside the appcast and pricing supplement. +### App Store Connect -The Pages site carries the custom domain **runway.page** (Settings → Pages → Custom domain, with Enforce HTTPS on; DNS is four A records on the apex plus a `www` CNAME to `mstallone.github.io`). GitHub then serves the old `mstallone.github.io/runway/…` paths as redirects to `runway.page`. Shipped apps still poll the `mstallone.github.io` feed URL through that redirect, so never let the domain lapse or detach without first shipping a release that changes `SUFeedURL` — see [Updates](updates.md#where-updates-come-from). +Create the app record (My Apps → New App, iOS, bundle ID `com.mattstallone.runway.mobile`, any unique SKU), and add an internal TestFlight tester group with **automatic distribution** so every uploaded build reaches internal testers without a manual step. The App ID must already have the CloudKit capability with both Runway containers (see [iOS app](ios-app.md)). + +The iOS jobs only run when the release needs them. The iOS Gate job skips Mac-only releases (no iOS-relevant changes since the last build the external testers received) unless that build is nearing its 90-day expiry. See [iOS app](ios-app.md#releasing-testflight). For external testers, the TestFlight External job submits each shipped release for Beta App Review, and testers receive it when Apple approves. The job runs in the **iOS TestFlight** GitHub environment, so the repository's Deployments panel shows the public join link (https://testflight.apple.com/join/uA4aHUEx) next to the Update Feed deployment. Its one-time setup has two steps. First, create an external group named `External` under TestFlight → External Testing, and add testers by email or enable a public link (the workflow's `TESTFLIGHT_EXTERNAL_GROUPS` env lists the group names it ships to). Second, fill in the app's TestFlight **Test Information**: the beta app description, the feedback email, and the review contact and sign-in details the first submission asks for. The app needs no demo account because it reads the tester's own iCloud data. Mark it as not requiring sign-in and say so in the review notes. + +### iCloud Sync + +Store the original development and Developer ID provisioning profiles in 1Password as secure documents. Install the development profile on each registered Mac. Base64-encode the Developer ID profile and store it only in the `APPLE_DEVELOPER_ID_ICLOUD_PROFILE` Actions secret. See [iCloud Sync](icloud-sync.md#development-and-release-setup) for the container identifiers, build command, and inspection command. + +### GitHub Pages and the domain + +The repository must be public (Sparkle fetches the DMG and appcast anonymously), and the update feed is served through GitHub Pages. Set Settings → Pages → Build and deployment → Source to **GitHub Actions**. The publishing workflows create and maintain the `update-feed` branch and deploy through the **Update Feed** environment. The landing page ships the same way: `website/` on `main` is published to `update-feed` by `.github/workflows/landing-page.yml` on merge, alongside the appcast and pricing supplement. + +The Pages site carries the custom domain **runway.page** (Settings → Pages → Custom domain, with Enforce HTTPS on; DNS is four A records on the apex plus a `www` CNAME to `mstallone.github.io`). GitHub serves the old `mstallone.github.io/runway/…` paths as redirects to `runway.page`. Shipped apps still poll the `mstallone.github.io` feed URL through that redirect, so never let the domain lapse or detach without first shipping a release that changes `SUFeedURL`. See [Updates](updates.md#where-updates-come-from). diff --git a/docs/research/codex-reset-credit-claim.md b/docs/research/codex-reset-credit-claim.md index 97c1d5257..9a41a5835 100644 --- a/docs/research/codex-reset-credit-claim.md +++ b/docs/research/codex-reset-credit-claim.md @@ -1,34 +1,24 @@ # Codex Rate-Limit Reset Credits: How Claiming Works -Research + live verification of the Codex "reset credit" claim flow, done 2026-07-12. -This file is the protocol reference for the shipped claim flow in -`Sources/Runway/Providers/Codex/CodexResetClaimService.swift`. +Research and live verification of the Codex "reset credit" claim flow, done 2026-07-12. This is the protocol reference for the claim flow in `Sources/Runway/Providers/Codex/CodexResetClaimService.swift`. -Sources: the open-source Codex CLI (`openai/codex`, `codex-rs/backend-client/src/client/rate_limit_resets.rs`, -`codex-rs/tui/src/chatwidget/reset_credits.rs`, `codex-rs/tui/src/chatwidget/usage.rs`, -`codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs`), plus a -live end-to-end claim against a real account (one credit, hours before it expired). +Sources: the open-source Codex CLI (`openai/codex`, `codex-rs/backend-client/src/client/rate_limit_resets.rs`, `codex-rs/tui/src/chatwidget/reset_credits.rs`, `codex-rs/tui/src/chatwidget/usage.rs`, `codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs`), plus a live end-to-end claim against a real account (one credit, hours before it expired). ## What a reset credit is -OpenAI grants Codex users occasional free "rate limit resets". Redeeming one immediately -resets the account's Codex rate-limit windows — on paid plans the 5-hour **and** weekly -windows together (`windows_reset: 2`); on Free/Go plans the monthly window. Credits expire -(typically 30 days after being granted) and are gone once redeemed or expired. +OpenAI grants Codex users occasional free "rate limit resets". Redeeming one immediately resets the account's Codex rate-limit windows: on paid plans the 5-hour and weekly windows together (`windows_reset: 2`), on Free/Go plans the monthly window. Credits expire (typically 30 days after grant) and are gone once redeemed or expired. ## Endpoints -Both live under the ChatGPT backend base URL (`https://chatgpt.com/backend-api`). The CLI -also has a `PathStyle::CodexApi` variant (`/api/codex/...` instead of `/wham/...`) for -enterprise/alternative base URLs; Runway uses the ChatGPT style. +Both live under the ChatGPT backend base URL (`https://chatgpt.com/backend-api`). The CLI also has a `PathStyle::CodexApi` variant (`/api/codex/...` instead of `/wham/...`) for enterprise or alternative base URLs. Runway uses the ChatGPT style. -Headers on every call (identical to what Runway's Codex usage client already sends): +Headers on every call (the same ones Runway's Codex usage client sends): - `Authorization: Bearer ` (the ChatGPT OAuth access token from `~/.codex/auth.json`) - `ChatGPT-Account-Id: ` (from the same file) - `Content-Type: application/json` on the POST -### List (already implemented in Runway) +### List `GET /wham/rate-limit-reset-credits` @@ -53,8 +43,7 @@ Headers on every call (identical to what Runway's Codex usage client already sen } ``` -Note: redeemed/expired credits drop out of the list entirely (after the live claim the -list had 3 entries, not 4 with one `redeemed`). +Redeemed and expired credits drop out of the list entirely. After the live claim the list had 3 entries, not 4 with one `redeemed`. ### Consume (the claim) @@ -67,17 +56,10 @@ list had 3 entries, not 4 with one `redeemed`). } ``` -- `redeem_request_id` — **idempotency key**, a plain UUID v4 minted by the client - (`Uuid::new_v4().to_string()` in the TUI). The CLI generates one key per credit shown in - its picker and **reuses the same key when the user retries after an error**, so a retry - can never burn a second credit; the server replies `already_redeemed`, which the CLI - treats as success. -- `credit_id` — optional. When present the server redeems exactly that credit; when - omitted the server picks one. The CLI always sends it (it sorts available credits by - soonest `expires_at` and lets the user pick; it only omits `credit_id` in a fallback - path when the detail list couldn't be fetched). +- `redeem_request_id`: the idempotency key, a UUID v4 minted by the client (`Uuid::new_v4().to_string()` in the TUI). The CLI generates one key per credit shown in its picker and reuses the same key when the user retries after an error, so a retry can never burn a second credit. The server replies `already_redeemed`, which the CLI treats as success. +- `credit_id`: optional. When present the server redeems exactly that credit. When omitted the server picks one. The CLI always sends it (it sorts available credits by soonest `expires_at` and lets the user pick). It omits `credit_id` only in a fallback path when the detail list could not be fetched. -Response (HTTP 200 even for the "failure" codes — the outcome is in `code`): +Response (HTTP 200 even for the failure codes; the outcome is in `code`): ```json { @@ -97,45 +79,26 @@ Response (HTTP 200 even for the "failure" codes — the outcome is in `code`): | code | meaning | credit burned? | |---|---|---| -| `reset` | success; `windows_reset` = number of windows reset (2 = 5h + weekly) | yes | -| `already_redeemed` | same `redeem_request_id` was already processed — treat as success | already was | -| `nothing_to_reset` | usage doesn't need a reset right now (CLI shows "Your usage does not need a reset right now.") | no | -| `no_credit` | the targeted credit is no longer available (raced away / expired), or none available at all | no | +| `reset` | success; `windows_reset` is the number of windows reset (2 = 5h + weekly) | yes | +| `already_redeemed` | the same `redeem_request_id` was already processed; treat as success | already was | +| `nothing_to_reset` | usage does not need a reset right now (CLI: "Your usage does not need a reset right now.") | no | +| `no_credit` | the targeted credit is no longer available (raced away or expired), or none available at all | no | -The consume response's `credit` object is richer than the CLI's own struct decodes — it -carries `redeem_started_at` / `redeemed_at` / `profile_*` fields the CLI ignores. +The consume response's `credit` object carries `redeem_started_at`, `redeemed_at`, and `profile_*` fields the CLI's own struct ignores. ## Live verification (2026-07-12, Pro plan) -Full verbose log (every request/response, token redacted): kept out of the repo; the run -was a one-shot Python script with hard guards (claim at most one credit, only the -soonest-expiring one, only if it expired within 4 h, explicit `credit_id`). - -- Before: 4 credits available; 5h window 96% used (reset in ~25 min), weekly 52% used - (reset in ~6 days). Target credit expired 2.18 h later. -- `POST …/consume` with a fresh UUID + explicit `credit_id` → HTTP 200, - `code: "reset"`, `windows_reset: 2`, credit `status: "redeemed"`. Round-trip ~1.1 s - (`redeem_started_at` → `redeemed_at` ≈ 0.7 s server-side). -- After (fetched ~1 s later): both the 5h and weekly windows read **0% used** with full - window durations (`reset_after_seconds` = 18000 / 604800), `available_count` = 3, and - the redeemed credit no longer appears in the list. The reset also zeroed the windows of - the `additional_rate_limits` entry (the model-specific limit was already 0%, so this is - suggestive, not proven). - -## Implementation notes for Runway (when we build it) - -- The claim is a single POST on infrastructure Runway already talks to; auth, headers, - and account id handling are identical to `CodexUsageClient`'s existing calls. -- Mint the `redeem_request_id` UUID **when the user is shown the claim affordance** (per - credit), persist it for the duration of the interaction, and reuse it on retry — that is - the CLI's double-spend protection and we should copy it exactly. -- Always pass an explicit `credit_id`; default the selection to the soonest-expiring - available credit (the CLI's sort order). -- Treat `already_redeemed` as success; surface `nothing_to_reset` as an informational - message (credit is *not* lost); on `no_credit` with a `credit_id`, refresh the list — - the credit raced away. -- This is an irreversible, user-visible spend of a scarce grant — the UI must be an - explicit, deliberate user action (the CLI uses a picker + confirmation flow), never - automatic. -- After a successful claim, refresh usage + the credit list immediately: both windows drop - to 0% and the count decrements, which the widgets should reflect right away. +The run was a one-shot Python script with hard guards (claim at most one credit, only the soonest-expiring one, only if it expired within 4 h, explicit `credit_id`). The full verbose log is kept out of the repo. + +- Before: 4 credits available; 5h window 96% used (reset in about 25 min), weekly 52% used (reset in about 6 days). Target credit expired 2.18 h later. +- `POST …/consume` with a fresh UUID and explicit `credit_id`: HTTP 200, `code: "reset"`, `windows_reset: 2`, credit `status: "redeemed"`. Round-trip about 1.1 s (`redeem_started_at` to `redeemed_at` about 0.7 s server-side). +- After (fetched about 1 s later): both the 5h and weekly windows read 0% used with full window durations (`reset_after_seconds` = 18000 / 604800), `available_count` = 3, and the redeemed credit no longer appears in the list. The reset also zeroed the windows of the `additional_rate_limits` entry (the model-specific limit was already 0%, so this is suggestive, not proven). + +## Implementation notes + +- The claim is a single POST on infrastructure Runway already talks to. Auth, headers, and account id handling are the same as `CodexUsageClient`'s existing calls. +- Mint the `redeem_request_id` UUID when the user is shown the claim control (per credit), keep it for the duration of the interaction, and reuse it on retry. That is the CLI's double-spend protection. +- Always pass an explicit `credit_id`. Default the selection to the soonest-expiring available credit (the CLI's sort order). +- Treat `already_redeemed` as success. Surface `nothing_to_reset` as an informational message (the credit is not lost). On `no_credit` with a `credit_id`, refresh the list, because the credit raced away. +- This is an irreversible spend of a scarce grant. The UI must be an explicit user action (the CLI uses a picker plus confirmation), never automatic. +- After a successful claim, refresh usage and the credit list immediately. Both windows drop to 0% and the count decrements. diff --git a/docs/research/grok-remaining-resets.md b/docs/research/grok-remaining-resets.md index 729a885b9..01a5cf7b7 100644 --- a/docs/research/grok-remaining-resets.md +++ b/docs/research/grok-remaining-resets.md @@ -1,24 +1,16 @@ # Grok Banked Usage Resets: How Listing Works -Research + live list (not redeem) of Grok's "Reset Available" tokens, done 2026-08-23. -This is the protocol reference for the shipped read-only row in -`Sources/Runway/Providers/Grok/GrokRemainingResetsDecoder.swift`. +Research and a live list (not redeem) of Grok's "Reset Available" tokens, done 2026-08-23. This is the protocol reference for the read-only row in `Sources/Runway/Providers/Grok/GrokRemainingResetsDecoder.swift`. -Sources: grok.com Settings → Usage, the public `GetRemainingResets` / `RedeemReset` -gRPC-web RPCs (`prod_mc_billing.ConsumerUiSvc`), plus a live **list-only** call with -the Grok CLI OAuth token from `~/.grok/auth.json`. Runway does not call `RedeemReset`. +Sources: grok.com Settings → Usage, the `GetRemainingResets` and `RedeemReset` gRPC-web RPCs (`prod_mc_billing.ConsumerUiSvc`), plus a live list-only call with the Grok CLI OAuth token from `~/.grok/auth.json`. Runway does not call `RedeemReset`. ## What a reset token is -xAI grants SuperGrok users occasional banked usage-limit resets (the "Reset Available / -Expires on …" card on Settings → Usage). Redeeming one immediately resets the weekly -shared pool. Tokens expire (observed live: 30 days after grant) and drop out of the -list once redeemed or expired. They do not stack past the list the RPC returns. +xAI grants SuperGrok users occasional banked usage-limit resets (the "Reset Available / Expires on …" card on Settings → Usage). Redeeming one immediately resets the weekly shared pool. Tokens expire (observed live: 30 days after grant) and drop out of the list once redeemed or expired. They do not stack past the list the RPC returns. ## Endpoints -Both live under `https://grok.com`. The Grok CLI's `GET /v1/billing?format=credits` -JSON does **not** carry this list. +Both live under `https://grok.com`. The Grok CLI's `GET /v1/billing?format=credits` JSON does not carry this list. Headers on the list call (gRPC-web empty request): @@ -44,18 +36,12 @@ ConsumerResetToken google.protobuf.Timestamp validity_end = 30; // expiry; field 1 = unix seconds ``` -A successful empty list is a known zero: an empty data frame plus `grpc-status: 0`. -Redeemed/expired tokens drop out of the list. Tokens missing an id or whose -`validity_end` is in the past are ignored, matching grok.com's own filter. +A successful empty list is a known zero: an empty data frame plus `grpc-status: 0`. Redeemed and expired tokens drop out of the list. Tokens missing an id or whose `validity_end` is in the past are ignored, matching grok.com's own filter. ### Redeem (not implemented) -`POST /prod_mc_billing.ConsumerUiSvc/RedeemReset` consumes a token. Runway never -calls this: claiming stays on grok.com. A display-only timeline is enough to see -how many resets remain and when they expire. +`POST /prod_mc_billing.ConsumerUiSvc/RedeemReset` consumes a token. Runway never calls this. Claiming stays on grok.com. A display-only timeline is enough to see how many resets remain and when they expire. ## Live list (2026-08-23) -The CLI OAuth bearer is accepted by grok.com for this RPC (no browser cookie, no -WKE). One still-valid token was present; `validity_end` was 30 days after -`granted_at`. The token was not redeemed. +The CLI OAuth bearer is accepted by grok.com for this RPC (no browser cookie needed). One still-valid token was present. `validity_end` was 30 days after `granted_at`. The token was not redeemed. diff --git a/docs/settings.md b/docs/settings.md index b4fea92f4..7a4aeeaba 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,65 +1,59 @@ # Settings -Settings opens in its own window — separate from the popover, so the dashboard stays a quick glance and Settings gets room to breathe. Open it from the popover footer's **gear** menu, with ⌘, while the popover is showing, or by right-clicking the menu bar icon and choosing Settings. Opening Settings closes the popover; close the window with the red close button, Esc, ⌘W, or ⌘Q. ⌘Q closes only the Settings window — Runway keeps running in the menu bar (quit it from the popover's power button or the menu bar icon's right-click menu). +Settings opens in its own window, separate from the popover. Open it from the popover footer's **gear** menu, with ⌘, while the popover is showing, or by right-clicking the menu bar icon and choosing Settings. Opening Settings closes the popover. Close the window with the red close button, Esc, ⌘W, or ⌘Q. ⌘Q closes only the Settings window. Runway keeps running in the menu bar. Quit it from the popover's gear menu or the menu bar icon's right-click menu. -The window is organized into four tabs — **General**, **Appearance**, **Notifications**, and **Advanced** — using the classic macOS preferences toolbar. It remembers the tab you were on, its size, and its position, and it only exists while it's open: a closed Settings window uses no memory or CPU at all, and reopening it is instant. +The window has four tabs: **General**, **Appearance**, **Notifications**, and **Advanced**. It remembers the tab you were on, its size, and its position. It only exists while it is open, so a closed Settings window uses no memory or CPU. -While Settings is open, Runway briefly appears in the Dock (the same as during an [update session](updates.md)) — that's what reliably brings the window to the front of a menu-bar-only app. It leaves the Dock again when you close the window. +While Settings is open, Runway briefly appears in the Dock, the same as during an [update session](updates.md). That is what brings the window to the front for a menu-bar-only app. It leaves the Dock when you close the window. ## General | Setting | Options | What it does | |---|---|---| -| Show Total Spend | on/off | Whether the cross-provider [Total Spend](dashboard.md#total-spend) card shows at the top of the dashboard. On by default; the card appears whenever at least one enabled provider tracks spend (Claude, Codex, Cursor, Grok, OpenCode, Sakana Fugu). | -| Launch at Login | on/off | Registers the app as a login item (the system's login-item registry is the source of truth). | -| Global Shortcut | record a shortcut | Global shortcut that toggles the popover from anywhere. Click the field and press a combo; the ⓧ clears it and disables the shortcut. | +| Show Total Spend | on/off | Whether the cross-provider [Total Spend](dashboard.md#total-spend) card shows at the top of the dashboard. On by default. The card appears whenever at least one enabled provider tracks spend (Claude, Codex, Cursor, Grok, OpenCode, Sakana Fugu). | +| Launch at Login | on/off | Registers the app as a login item. The system's login-item registry is the source of truth. | +| Global Shortcut | record a shortcut | Toggles the popover from anywhere. Click the field and press a combo. The ⓧ clears it. | ### iCloud Sync -**Sync Across Macs** is on by default (turn it off here to keep this Mac local-only). It shares -normalized Runway history and each -device's latest usage snapshot through the app's private CloudKit database, and combines -machine-local tokens and spend across Macs signed into the same iCloud account. Settings shows the -five-minute write cadence and each device's relative **Updated** time; it also reports unavailable -iCloud, loading, write, and malformed-record states. See [iCloud Sync](icloud-sync.md) for what is -included and which surfaces use the combined values. +**Sync Across Macs** is on by default. Turn it off here to keep this Mac local-only. It shares normalized Runway history and each device's latest usage snapshot through the app's private CloudKit database, and combines machine-local tokens and spend across Macs signed into the same iCloud account. Settings shows the five-minute write cadence and each device's relative **Updated** time. It also reports unavailable iCloud, loading, write, and malformed-record states. See [iCloud Sync](icloud-sync.md). ### Privacy | Setting | Options | What it does | |---|---|---| -| Hide From Screen Share | On / Off | Off (default). On replaces the menu bar strip with the Runway icon and wordmark while your screen is being shared or recorded, and restores your starred metrics the moment the capture ends. See [Menu bar](menu-bar.md#hiding-usage-while-screen-sharing). | +| Hide From Screen Share | On / Off | Off by default. On replaces the menu bar strip with the Runway icon and wordmark while your screen is shared or recorded, and restores your starred metrics when the capture ends. See [Menu bar](menu-bar.md#hiding-usage-while-screen-sharing). | ## Appearance | Setting | Options | What it does | |---|---|---| | Icon Style | Text / Bars | How starred metrics render in the menu bar. See [Menu bar](menu-bar.md). | -| Theme | System / Light / Dark | App-wide appearance override for the popover and the Settings window. | -| Time Format | Auto / 12-hour / 24-hour | How exact times read (e.g. "Resets today at 6:38 PM" vs "18:38"). Auto follows the system. | -| Increase Transparency | Off / On | Off (default) keeps the popover a solid panel. On makes it translucent so your desktop shows through, while keeping the numbers and footer controls legible with adaptive frosted surfaces. It pauses automatically when you have the macOS **Reduce Transparency** or **Increase Contrast** accessibility setting turned on (a note explains why), so it never works against those preferences. | +| Theme | System / Light / Dark | Appearance override for the popover and the Settings window. | +| Time Format | Auto / 12-hour / 24-hour | How exact times read ("Resets today at 6:38 PM" vs "18:38"). Auto follows the system. | +| Increase Transparency | Off / On | Off by default. On makes the popover translucent so your desktop shows through, with frosted surfaces behind the numbers and footer controls. It pauses when the macOS **Reduce Transparency** or **Increase Contrast** accessibility setting is on, and a note says so. | ### Usage Display | Setting | Options | What it does | |---|---|---| -| Show Usage As | Used / Left | Whether bounded metrics read "48% used" or "52% left" — same toggle as clicking a headline. | -| Reset Times | Countdown / Exact time | "Resets in 3h 25m" vs "Resets today at 6:38 PM" — same toggle as clicking a reset label. | -| Always Show Pacing | Off / On | Off (default) shows pacing only when a metric is close to or over its limit. On surfaces it on every metric with a reset window: on-track rows gain their projection ("~33% left at reset") and an even-pace tick that marks where steady use puts you right now. Metrics without a reset window have no pace to show, and a metric with nothing used yet stays plain until something has been spent. | +| Show Usage As | Used / Left | Whether bounded metrics read "48% used" or "52% left". Same toggle as clicking a headline. | +| Reset Times | Countdown / Exact time | "Resets in 3h 25m" vs "Resets today at 6:38 PM". Same toggle as clicking a reset label. | +| Always Show Pacing | Off / On | Off by default shows pacing only when a metric is close to or over its limit. On shows it on every metric with a reset window: on-track rows gain their projection ("~33% left at reset") and an even-pace tick. Metrics without a reset window have no pace to show. A metric with nothing used yet stays plain. | ## Notifications -Runway can alert you with a macOS notification when a metric runs low or its pace gets worse, so you don't have to keep the popover open to catch a quota creeping toward its limit. Alerts work while the app runs in the menu bar, even with the popover closed. +Runway can send a macOS notification when a metric runs low or its pace gets worse. Alerts work while the app runs in the menu bar, even with the popover closed. | Setting | Options | What it does | |---|---|---| | Almost Out | On / Off | Alerts when a metric crosses under 10% remaining, including balances without a reset window. | -| Cutting It Close | On / Off | Alerts when a metric is projected to finish the period with little left — close to its limit. | +| Cutting It Close | On / Off | Alerts when a metric is projected to finish the period close to its limit. | | Will Run Out | On / Off | Alerts when a metric is projected to run out before it resets. | -Alerts fire on a new crossing or pace worsening, then stay deduplicated while that condition is unchanged, so you do not get repeats on every refresh. A quota already in a bad state when Runway launches establishes the baseline without alerting. If it recovers and later worsens again, the alert re-arms; a new reset period also clears the reset-based history. **Almost Out** is based only on the remaining share, so it also works for bounded balances without a reset window. **Cutting It Close** and **Will Run Out** require reset-window pace context. Metrics whose data cannot be read never alert. Turn all three triggers off to silence everything. When several alerts fire at once, they stack into a single grouped banner. +Alerts fire on a new crossing or when pace worsens, then stay quiet while that condition is unchanged. A quota already in a bad state when Runway launches sets the baseline without alerting. If it recovers and later worsens again, the alert fires again. A new reset period also clears the reset-based history. **Almost Out** uses only the remaining share, so it also works for balances without a reset window. **Cutting It Close** and **Will Run Out** need a reset window. Metrics whose data cannot be read never alert. Turn all three off to silence everything. Several alerts at once stack into one grouped banner. -All three alerts default off. The first time you turn one on, Runway asks for notification permission. If you decline (or turn notifications off for Runway in System Settings later), a warning mark appears on the Notifications header, and an "Open System Settings" button shows under the toggles so you can re-enable them. A notification's title is the alert name, its subtitle names the provider and metric, and its body is the plain-language verdict. Tapping an alert opens the popover on the dashboard. +All three default off. The first time you turn one on, Runway asks for notification permission. If you decline, or later turn notifications off for Runway in System Settings, a warning mark appears on the Notifications header and an "Open System Settings" button shows under the toggles. A notification's title is the alert name, its subtitle names the provider and metric, and its body is the plain-language verdict. Tapping an alert opens the popover. ## Advanced @@ -67,34 +61,33 @@ All three alerts default off. The first time you turn one on, Runway asks for no | Setting | Options | What it does | |---|---|---| -| Terminal Helper | Install / Uninstall | Adds a global `runway` command agents can use to monitor limits. See [CLI](cli.md). | +| Terminal Helper | Install / Uninstall | Adds a global `runway` command that agents can use to read limits. See [CLI](cli.md). | ### Logging | Setting | Options | What it does | |---|---|---| -| Log Level | Error / Warning / Info / Debug | How much detail the app writes to its log file. Defaults to Info and persists across launches; raise to Debug while reproducing a problem. Applies immediately. | +| Log Level | Error / Warning / Info / Debug | How much detail the app writes to its log file. Defaults to Info and persists across launches. Raise to Debug while reproducing a problem. Applies immediately. | | Copy Log Path | button | Copies the log file path (`~/Library/Logs/Runway/Runway.log`) to the clipboard. | | Reveal in Finder | button | Opens a Finder window with the log file selected. | -See [Logging](logging.md) for the full behavior: subsystem tags, the file size cap, and the guarantee that secrets are never written. +See [Logging](logging.md) for subsystem tags, the file size cap, and what is never logged. ### Updates -The Updates section appears in official packaged builds that include the signed update feed. Local -developer builds do not show it. +The Updates section appears in official packaged builds that include the signed update feed. Local developer builds do not show it. | Setting | Options | What it does | |---|---|---| | Update Automatically | On / Off | Whether Sparkle checks for updates in the background. You can still check manually when this is off. | | Check for Updates… | button | Starts a manual update check and opens Sparkle's update window. | -See [Updates](updates.md) for the dashboard banner and signature verification. +See [Updates](updates.md). ## Version The app version shows in the popover footer. -Your settings carry across updates — layout, stars, preferences, and the menu-bar shortcut all stay put. When an update changes how a setting is stored, the app upgrades it in place on launch, stepping through any in-between versions if you skipped a few. Nothing is reset. +Your settings carry across updates: layout, stars, preferences, and the shortcut. When an update changes how a setting is stored, the app upgrades it in place on launch, stepping through any skipped versions. Nothing is reset. -Which providers you have on also carries across updates — your choices are never overridden. A brand-new install picks its starting set by detecting the AI tools on your Mac (see [Dashboard § First launch](dashboard.md#first-launch)). When an update ships a provider you've never seen, the same local detection runs once for just that provider and turns it on only if you actually have the tool; everything you've already decided about stays exactly as you set it. See [Which Providers Are On](provider-enablement.md). +Which providers you have on also carries across updates. A new install picks its starting set by detecting the AI tools on your Mac (see [Dashboard § First launch](dashboard.md#first-launch)). When an update ships a provider you have never seen, the same local detection runs once for that provider and turns it on only if you have the tool. See [Which Providers Are On](provider-enablement.md). diff --git a/docs/updates.md b/docs/updates.md index 3c781378b..fc3425735 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,35 +1,17 @@ # Updates -Runway keeps itself up to date using [Sparkle](https://sparkle-project.org), the standard update -framework for Mac apps. The app downloads updates from Runway's own release feed and verifies them -before they install, so you always get a genuine, unmodified build. +Runway updates itself with [Sparkle](https://sparkle-project.org), the standard update framework for Mac apps. The app downloads updates from Runway's own release feed and verifies them before they install. ## How it works -- **Automatic checks.** The app quietly checks for a new version in the background (about once an hour). - When one is found, an **Update Available** banner appears at the top of the popover instead of a - window popping up behind your other apps. Click **Install Update** to open the update window (release - notes, download, install) front and center. The banner's close button snoozes it; it comes back the - next time the app finds the update. -- **Manual check.** Open **Settings → Advanced → Updates** and click **Check for Updates…** at any time. - For both manual checks and banner installs, Runway brings itself to the foreground before opening - Sparkle so the update window doesn't get buried behind another app. Because Runway normally lives - only in the menu bar, it briefly shows a Dock icon for the update session, then hides again. -- **Turn it off.** The **Update Automatically** switch in **Settings → Advanced → Updates** stops the - background checks. You can still check manually. +- **Automatic checks.** The app checks for a new version in the background about once an hour. When one is found, an **Update Available** banner appears at the top of the popover. Click **Install Update** to open the update window (release notes, download, install). The banner's close button snoozes it until the next time the app finds the update. +- **Manual check.** Open **Settings → Advanced → Updates** and click **Check for Updates…**. For manual checks and banner installs, Runway brings itself to the foreground before opening Sparkle so the update window is not buried behind another app. Because Runway normally lives only in the menu bar, it briefly shows a Dock icon for the update session, then hides it again. +- **Turn it off.** The **Update Automatically** switch in **Settings → Advanced → Updates** stops the background checks. You can still check manually. ![Stable-only update settings](assets/updates-stable-only.png) ## Where updates come from -Runway publishes stable update builds on its GitHub releases and serves the list of available versions -(the "appcast") from `https://mstallone.github.io/runway/appcast.xml`. That address is deliberate: -it is baked into every shipped app, and it can outlive the custom domain — if the domain is ever -detached in the Pages settings, the address serves the feed directly again. Today it redirects to -`https://runway.page/appcast.xml`, the same GitHub Pages site behind the -[runway.page](https://runway.page/) landing page. While the redirect is in place the feed depends -on the domain, so keep `runway.page` registered and working — and if it must ever go away, detach -it in the Pages settings so the redirect stops; never leave a dead domain configured. Each download is -signed two ways — Apple notarization plus Runway's own signature — and the app refuses anything that -doesn't match. This is only available in the official signed release build, not in local developer -builds. +Runway publishes update builds on its GitHub releases and serves the list of versions (the appcast) from `https://mstallone.github.io/runway/appcast.xml`. That address is baked into every shipped app. Today it redirects to `https://runway.page/appcast.xml`, the same GitHub Pages site as the [runway.page](https://runway.page/) landing page. While the redirect is in place, the feed depends on the domain, so keep `runway.page` registered and working. If the domain must ever go away, detach it in the Pages settings so the `mstallone.github.io` address serves the feed directly again. Never leave a dead domain configured. + +Each download is signed two ways, Apple notarization plus Runway's own Sparkle signature, and the app refuses anything that does not match. Updates are only available in the official signed release build, not in local developer builds.