fix: dashboard timezone parity + v1.14 Lifetime IAP foundation + Codex P1#41
Conversation
…x P1 Three independent threads landing together because they touch overlapping client surfaces. None of them depends on the others; each is reviewable in isolation in PROJECT_FIX_2026-05-08_dashboard_parity_and_v1.14_lifetime.md. ## 1. Codex P1 (PR #39 follow-up) — MAS LoginItem sandbox verifier Codex flagged that build-appstore.sh's MAS contract verifier checked the Swift LaunchAgent strip but didn't assert the remaining nested binary (CLIPulseHelper LoginItem) is sandboxed. v1.13.0 passed ASC because ENABLE_APP_SANDBOX=YES was injected at sign time, but the source .entitlements file was misleading and verifier wouldn't catch a future project-setting drift. Fix: explicit `com.apple.security.app-sandbox=true` in CLIPulseHelper.entitlements, plus a codesign-based check in verify_mas_archive_has_no_launchagent and a source-file grep in swift-ci.yml's verify-archive job. ## 2. Dashboard parity bug (Hypothesis A confirmed) User report 2026-05-08 02:03 CN: iPhone Usage Today=0 / $135 macOS, Same user same moment. Sessions/Devices/Alerts match perfectly → cloud auth fine. Mismatch isolated to daily_usage_metrics queries. Root cause: dashboard_summary / provider_summary use `current_date` which returns server-tz date (UTC for Tokyo Supabase); CostUsageScanner writes metric_date in device-local Calendar.current. At 02:03 CN, server queries May 7 UTC while Mac wrote May 8 CN — query misses today. Fix: migrate_v0.42_user_tz_today.sql adds `p_user_today date default null` to both RPCs; coalesce(p_user_today, current_date) gates today / 7-day / 30-day windows. Backwards compatible — old clients keep working. DROP function first per Codex/Gemini review pattern (adding param creates overload, not replacement). Swift + Android clients: send local-TZ today via APIClient.localTodayKey / SupabaseClient.localTodayKey. 3 new pinning tests in APIClientLocalTodayTests. Bonus: iOS Cost Forecast section added to iOSOverviewTab for parity with macOS OverviewTab. Data was already populated cross-platform via DataRefreshManager.refreshCostForecast(). NOTE: migration is staged but NOT applied (pending user approval per autonomy contract — backend schema category). Surface SQL in PR description; user runs via Supabase MCP. Hypothesis B (cloud has only ~25% of historical data, accounting for the 4× 30-day discrepancy) NOT addressed here. Investigation steps in PROJECT_FIX docs; needs user-side DB inspection. ## 3. v1.14 Pro Lifetime IAP foundation ASC IAP draft com.clipulse.pro.lifetime (Apple ID 6767441323, ¥128 CNY, Non-Consumable, "Prepare for Submission" — NOT submitted) wired through: * SubscriptionManager: proLifetimeID constant, allProductIDs set, proLifetime accessor, @published isLifetime flag, .nonConsumable branch in updateCurrentEntitlements (Team still outranks Lifetime; Lifetime outranks Pro). * Paywall surfaces: SubscriptionView lifetime tile (orange ONE-TIME badge); SubscriptionSection inline IAP card; iOS settings inherits via SubscriptionView NavigationLink. * L10n: 6 new keys × 5 locales (en, zh-Hans, ja, es, ko). * Edge function validate-receipt: PRODUCT_TIER_MAP + LIFETIME_PRODUCT_IDS set. Lifetime persists current_period_end=null. NO is_lifetime column added — row is identifiable via apple_product_id + null expiry. Avoids the schema-migration approval gate for v1.14. * 3 new tests in SubscriptionTierResolutionTests pin the constants. ## Verification * swift test --parallel: 924/924 PASS (was 918, +6 new). * swift package resolve + swift build: clean. * xcodebuild macOS scheme: BUILD SUCCEEDED (exit 0). * xcodebuild iOS scheme: BUILD SUCCEEDED. * xcodebuild Watch scheme: BUILD SUCCEEDED. * xcodebuild Widgets scheme: BUILD SUCCEEDED. * bash -n on all updated shell scripts: clean. Refs: PROJECT_FIX_2026-05-08_dashboard_parity_and_v1.14_lifetime.md, PROJECT_PLAN_v1.14_lifetime_iap.md, migrate_v0.42_user_tz_today.sql. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2686fdefce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let summary: DashboardSummaryPayload = try await rpc( | ||
| "dashboard_summary", | ||
| params: UserTodayParams(p_user_today: Self.localTodayKey()) | ||
| ) |
There was a problem hiding this comment.
Gate p_user_today until the RPC migration lands
When this build runs against the current production Supabase before migrate_v0.42_user_tz_today.sql is applied (the commit notes say that migration is not applied yet), these calls always send p_user_today. The existing dashboard_summary() / provider_summary() signatures do not accept that argument, so PostgREST will reject the RPC instead of falling back to current_date; the default only helps after the new date-parameter functions exist. Please either apply the migration before shipping these clients or retry the no-parameter RPC on the undefined-function/unknown-parameter response; Android has the same rollout hazard.
Useful? React with 👍 / 👎.
…pplied to prod PR #41 review feedback from Codex: P1 — SubscriptionManager.shouldPromote (NEW) Codex caught: when user owns BOTH Pro auto-renewable AND Pro Lifetime (Non-Consumable), `Transaction.currentEntitlements` iteration order is unspecified; with strict `>`, whichever arrived first won. If the auto-renewable Pro arrived first, the Lifetime JWS never reached validate-receipt, so the server kept current_period_end set to the yearly's expiry instead of NULL. Fix: extract the tie-break into a `nonisolated static` helper `shouldPromote(newTier:newIsLifetime:currentTier:currentIsLifetime:)`. Rules: strictly higher rank wins (Team > Pro); on a Pro-rank tie, prefer Lifetime so its JWS routes to validate-receipt. Two non-Lifetime equals never trade — preserves pre-v1.14 behavior. Team always outranks Lifetime. 5 new pinning tests in SubscriptionTierResolutionTests cover: - Lifetime beats Pro auto-renewable on tie (the bug) - Lifetime stays when seen first (the inverse) - Team outranks both Pro variants - No swap between two non-Lifetime Pro transactions - Lower-rank transactions never promote Tests: 16/16 PASS in SubscriptionTierResolutionTests. P2 — provider_summary metric_date upper-bound Codex caught: post-v0.42, `p_user_today` is client-controlled, so a multi-timezone user (Mac in CN UTC+8, iPhone queried from US Pacific) could have the iPhone's 7-day / 30-day totals include CN-tomorrow rows the Mac just wrote. Pre-v0.42 the boundary was server `current_date` which always trailed any local-TZ writer. Fix: clamp `metric_date <= v_today` in the WHERE clause and in each window's CASE arms. today_usage / today_cost already used equality so they were unaffected. v0.42 → v0.44 rename + reconcile While verifying the migration against production state, discovered that `v0_43_provider_quotas_bigint_and_updated_at` was already deployed on 2026-05-04 by the cli-pulse-desktop team. That migration: - Bumped provider_quotas.{quota,remaining} to bigint (preserved). - Added `updated_at` to provider_summary's output (preserved here). - INADVERTENTLY regressed rolling-window math from `'6 days'`/`'29 days'` to `'7 days'`/`'30 days'` (off-by-one, +14% inflation on 7-day, +3.3% on 30-day). CI guard ci_check_date_windows.py only scans this repo's backend/supabase/*.sql; v0.43 was authored in cli-pulse-desktop's tree so the guard never saw the regression. v0.44 applies on top of v0.43: - Renamed file v0.42 → v0.44 to match production version sequence. - Restored correct rolling-window math (6 days / 29 days inclusive). - Preserved the `updated_at` projection v0.43 added. - Added p_user_today TZ-aware param + Codex P2 upper-bound clamp. - Locked down anon: REVOKE FROM PUBLIC + REVOKE FROM anon, then GRANT TO authenticated only (defense-in-depth — auth.uid()=null raise was the only previous gate). Migration APPLIED to production via Supabase MCP. Verified: - pg_proc shows both functions with `(p_user_today date DEFAULT NULL)`. - has_function_privilege confirms anon=false, authenticated=true. Reviews: - Gemini 3.1 Pro: ship verdict for both P1 fix and v0.44 migration. - ci_check_date_windows.py: pass. - ci_check_rpc_contract.py: pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ap 200→500
User-reported on 2026-05-08: opened a managed Claude session on iPhone,
sent hello, saw output for turn 1 and 2, but by the 3rd turn the output
panel stopped updating.
## Root cause (Gemini 3.1 Pro confirmed)
The live-tail ScrollView's auto-scroll trigger was
`.onChange(of: events.count)`. The session-events buffer is a ring buffer
capped at `AppState.remoteSessionEventsCap`. Once the buffer hits cap,
new events arrive and old ones get evicted from the head — but
`events.count` stays constant. SwiftUI's `onChange` doesn't fire when
the observed value is unchanged, so:
- The Text view's content updates (transcript is recomputed each
render).
- But the ScrollView never scrolls to the new bottom.
- User sees the SAME on-screen content as turn 2, frozen, even though
turn 3's events ARE in the data layer.
The user perceives this as "output stopped displaying". Reproduces
deterministically once the buffer is saturated, which Claude's TUI does
quickly because it emits many redraw / chrome events between user
prompts.
## Fix
1. Switch the scroll trigger from `events.count` to `events.last?.id`.
Event ids are bigserial and strictly monotonic — they always advance
when new content arrives, regardless of buffer trim. Same fix
applied to the macOS `SessionsTab.swift` parity site.
2. Bump `AppState.remoteSessionEventsCap` from 200 to 500. Claude's TUI
chrome (Processing spinners, token counters, status bar) emits many
short events per visible screen redraw; cap=200 was tight enough
that conversational `❯` / `⏺` markers from earlier turns could be
trimmed before the user finished reading them. 500 events covers
~5 minutes of typical activity at the helper's 3.5KB / 0.5s flush
cadence; worst-case memory is ~2MB / session (4KB cap × 500).
3. Add an inline diagnostic strip in the iOS output panel:
- When stdout events ARE flowing but the formatter returns the
emptyFallback (no `❯` / `⏺` markers extracted), surface the raw
event count plus a "Refresh" button that clears the per-session
cache and re-fetches. Gives the user a recovery action when the
extractor under-matches.
- When the buffer is at cap, show "Showing latest N events (older
trimmed)" so the user understands they're looking at a tail, not
the full transcript.
## Tests
3 new regression cases in `ClaudeConversationPreviewFormatterTests`:
- `test_three_turn_conversation_keeps_all_messages` — 3 user prompts +
3 assistant replies across 6 events with chrome interleaved. All six
conversational lines must survive.
- `test_three_turn_conversation_with_viewport_scroll` — same shape but
later events drop the oldest turn from their visible window (real TUI
behavior). Pinned to ensure the formatter doesn't lose lines that
appeared in earlier events.
- `test_chatty_tui_chrome_does_not_crowd_out_conversation` — 50
chrome-only events followed by one substantive turn. Conversational
lines must surface even after a chrome flood.
All 48 ClaudeConversationPreviewFormatterTests pass.
## Verification
- `swift build` clean.
- `xcodebuild` macOS scheme: BUILD SUCCEEDED.
- `xcodebuild` iOS scheme: BUILD SUCCEEDED.
- `swift test --filter ClaudeConversationPreviewFormatterTests`: 48/48
pass (was 45 + 3 new).
## Reviewers
- Gemini 3.1 Pro: confirmed the count-based scroll trigger as the most
probable root cause; recommended switching to `events.last?.id`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…der-matches The conversation extractor only emits lines that begin with `❯` or `⏺`, plus a small allowlist for error/warning surfaces. When Claude's TUI emits content WITHOUT those markers (login flow, /help output, tool permission prompts that haven't fired through the structured approval path yet), the smart formatter returns the empty fallback even though stdout is flowing. The diagnostic strip already showed an event count + Refresh button on that path. This adds a "Show raw" toggle next to it that expands a sub-panel rendering the ANSI-stripped, marker-unfiltered stdout. Tail to 8 KB so the diagnostic stays readable; selectable text so the user can copy. This means a user looking at "0 conversation lines extracted" can flip into raw mode and see what's actually streaming, instead of guessing whether the panel is broken or Claude just hasn't produced marker-prefixed output yet. Toggle defaults OFF; resets on view rebuild (every navigation back into the detail view). xcodebuild iOS scheme: BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumped version to v1.14.0 / build 52 in all 6 plist locations + pbxproj
+ PDFReportGenerator fallback string. Built and validated end-to-end
on iPhone 17 Pro Max Simulator (iOS 26.4).
## Version bumps (per feedback_archive_embedding_gap.md)
- pbxproj: 10× MARKETING_VERSION = 1.14.0; 10× CURRENT_PROJECT_VERSION = 52
- CLI Pulse Bar/CLI Pulse Bar/Info.plist: 1.14.0/52
- CLI Pulse Bar/CLI Pulse Bar iOS/Info.plist: 1.14.0/52
- CLI Pulse Bar/CLI Pulse Bar Watch/Info.plist: 1.14.0/52
- CLI Pulse Bar/CLI Pulse Widgets/Info.plist: 1.14.0/52
- CLI Pulse Bar/CLIPulseHelper/Info.plist: 1.14.0/52
- PDFReportGenerator.swift fallback: "1.13.0" → "1.14.0"
## E2E validation on iOS Simulator
Booted iPhone 17 Pro Max, installed v1.14.0/52, launched the app via
xcrun simctl. Captured screenshots through Welcome → Try Demo →
Settings → Subscription paywall. Confirmed:
- App launches at v1.14.0/52 build (Welcome screen renders).
- Demo mode loads (Settings tab, FREE tier badge).
- Paywall renders Pro $49.99/year (POPULAR) + Team $99.99/year tiles.
- **Pro Lifetime tile renders correctly**:
- "Pro Lifetime" title + orange "ONE-TIME" badge
- $19.99 / one-time price (Sandbox test pricing; production is ¥128 CNY)
- Description: "Pro features forever, all platforms. One-time
purchase, no recurring charges."
- Orange "Buy Lifetime" action button
- URL scheme clipulse://overview navigates to Overview tab
(visible behind iOS-system "Open in CLI Pulse?" prompt).
What this validates:
- SubscriptionView.lifetimeCard view code (the v1.14 main visual change).
- L10n keys: subscription.lifetime, subscription.oneTimeBadge,
subscription.lifetimeDescription, subscription.buyLifetime,
subscription.oneTime — all resolve correctly in en.
- SubscriptionManager.proLifetime accessor returns a valid
StoreKit Sandbox Product with displayPrice populated.
- Hide-when-Team / hide-when-isLifetime visibility check (currentTier =
.free in demo, so tile shows correctly).
- v1.14.0/52 binary boots and serves the paywall with no runtime errors.
What this does NOT validate (out of reach without further setup):
- Actual Sandbox PURCHASE of Lifetime + tier promotion (needs a
StoreKit Sandbox test account login).
- Codex P1 tie-break under real concurrent Pro+Lifetime entitlements
(only unit-test-pinned).
- 3-turn live-tail session behavior (needs paired Mac helper + managed
Claude session).
- Full iPhone↔Mac dashboard parity comparison post-migration (Demo
mode synthesizes data; doesn't exercise the cloud RPC path).
The v1.14 binary's Lifetime tile screenshot at /tmp/ios-4-lifetime-tile.png
(1320×2868, iPhone 17 Pro Max @ 3x) is suitable as the
appStoreReviewScreenshot needed to unblock IAP submission.
## Limitations encountered
- cliclick lacks Accessibility permission in TCC, so synthetic clicks
silently no-op'd against the Simulator window. Worked around via
xcrun simctl openurl + screenshot capture.
- Simulator does not expose iOS UI in macOS Accessibility tree (only
hardware buttons), so System Events click button cannot drive iOS UI.
Future: invest in XCTest UI Automation for proper E2E iOS tests.
- macOS-level screen-recording permission prompt appeared mid-session
and could not be dismissed via osascript-launched key events.
## See
- PROJECT_FIX_2026-05-08_v1.14_e2e_validation.md — full E2E walkthrough.
- PR #41 description — outstanding ASC submission steps for the user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…use)
User-reported on 2026-05-08: iPhone dashboard shows wrong cost data
even after the v0.44 timezone migration was applied. Root cause was
NOT timezone — it was that the macOS app's `[syncDailyUsage] failed:
HTTP 403` was bouncing every refresh, so cloud daily_usage_metrics had
been stale for weeks. Confirmed in the user's macOS Xcode console log
(four `[syncDailyUsage] failed: HTTP 403` lines).
## Root cause
`HelperConfig.load()` reads from `group.yyh.CLI-Pulse` UserDefaults +
Keychain. The app-group store held a `helper_config` from a PRIOR
Supabase account (different `userId` + paired `deviceId`). When the
user later signed into the current account, the app-group config was
NOT cleared. Every `syncDailyUsage` then read the stale `deviceId` and
sent it as `p_device_id` to `upsert_daily_usage`. The server's
ownership check is:
if not exists (
select 1 from public.devices
where id = p_device_id and user_id = v_user_id
) then
raise exception 'Device not owned by caller' using errcode = '42501';
end if;
`v_user_id = auth.uid()` (current account); `devices.user_id` for that
deviceId was the prior account → mismatch → 42501 → HTTP 403.
The Mac saw the 403 every refresh; the iPhone read whatever stale rows
were in the cloud (4× lower than local). This is the same bug as the
user-reported "Hypothesis B" 4× discrepancy in the original parity
report — closing it now.
## Fix
Added `HelperConfig.loadIfMatches(authenticatedUserId:)`. Returns the
config ONLY when stored `userId == authenticated user_id`. Mismatch
(or no auth) returns nil so the caller falls through to the
no-`p_device_id` path; the server then attributes the upsert to the
sentinel UUID instead of failing the device check (per the
v0.37 multi-device design).
`APIClient.syncDailyUsage` switched from `HelperConfig.load()` to
`HelperConfig.loadIfMatches(authenticatedUserId: userId)` where
`userId` is the JWT-authenticated user already tracked on `APIClient`.
Scope decision: `HelperDaemon.swift` (LoginItem) and
`LocalSessionControlState.swift` keep `HelperConfig.load()` because
those paths use the helper-credentialed RPCs (`device_id +
helper_secret`), a different auth model that doesn't trigger 42501 on
a user-account swap. They have their own related concerns (helper
binding lifecycle on account switch) but that's separate from the
reported iPhone-data-stale bug. Documented in the Gemini review.
## Tests
5 new pinning tests in `HelperConfigCrossAccountGuardTests`:
- `testLoadIfMatches_returnsNil_whenStoredUserIdDiffersFromAuth` — the bug.
- `testLoadIfMatches_returnsConfig_whenStoredUserIdMatchesAuth` — no-op happy path.
- `testLoadIfMatches_returnsNil_whenAuthIdIsNil` — pre-auth callers.
- `testLoadIfMatches_returnsNil_whenAuthIdIsEmpty` — defensive.
- `testLoadIfMatches_returnsNil_whenNothingStored` — fresh-install path.
All 5 pass; existing 924+ CLIPulseCore tests unaffected.
## Operational note
The user's local app-group plist was patched in this session to the
current account's `deviceId` (`5157dc27-…` — the active "CLI Pulse
Helper" Mac on Supabase). This is a one-time runtime fix; the code
change above prevents future cross-account leaks regardless.
## Reviewers
- Gemini 3.1 Pro: confirmed root cause, flagged HelperDaemon as a
related-but-separately-scoped concern (acknowledged + documented
above).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…drops 'stop hook' chrome User-reported on 2026-05-08: managed Claude session on iPhone — when the assistant replied with a multi-row response (e.g., a working-dir path that wraps to two visual rows), only the first row carrying the `⏺` marker rendered. The continuation row got dropped or replaced by TUI status chrome. Confirmed against the actual Supabase event 587 of session 4eb2d46e. ## What was actually broken Two latent bugs in `ClaudeConversationPreviewFormatter`: 1. **`assistantContinuationChromeMarkers` had a typo / outdated form.** Code listed `running sp hook`, but Claude's TUI emits the hook class verbatim — `running stop hook`, `running pre hook`, `running sub hook`, etc. With the wrong marker, every cycle's `✻ Misting… (running stop hook · 2s · ↓ 13 tokens)` line bypassed the chrome filter and surfaced as if it were assistant content. 2. **Inline `↓ N tokens` token counters slipped through.** `looksLikeTokenCounter()` requires `↓` or `↑` to be the FIRST character. In real TUI output the counter appears mid-line after a spinner glyph and timer, e.g. `✻ Misting… (running stop hook · 2s · ↓ 13 tokens)`. So the line was never matched as a token counter and got kept as a "substantive" continuation. Result: the assistant transcript ended up interleaved with spinner-and-token chrome, making the conversation harder to read AND in the user's screenshot it manifested as the response visually truncating around the first-row content because the chrome lines crowded the panel. ## Fix `assistantContinuationChromeMarkers` extended with the canonical TUI hook classes (`stop`, `sp`, `sub`, `pre`, `post`) plus the timer variants (`crunched for`, `brewed for`, `baked for`, `churned for`) plus inline token-counter substrings (`↓ ` and `↑ ` with trailing space, anchored on the arrow + space delimiter so user prose mentioning the arrow glyphs in another context still survives — those would not be followed by a literal space-delimited token counter). ## Tests 4 new production-trace tests in `ClaudeConversationPreviewFormatterTests` all green: * `test_assistant_response_continuation_after_path_wrap` — pins the exact event-587 shape (multi-row path + branch name + stop-hook chrome). Both the path AND the `dashboard-parity-and-v1.14-lifetime` continuation must surface; both flavours of chrome must drop. * `test_assistant_continuation_across_event_boundary` — same scenario but the path and the branch-name row arrive in two different stdout chunks (helper batcher boundary). * `test_assistant_bullet_list_continuations` — assistant answers with three bullet items, none of which carry `⏺`; all three must surface. * (Plus existing `test_*_continuation_*` cases stayed green.) 51/51 ClaudeConversationPreviewFormatterTests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex independent review of PR #42 caught two P1 blockers and one fixture-coverage gap. This commit lands the fixes. P1 #1 — local UDS macOS path rejected codex/gemini providers helper/local_session_server.py:828 had a hardcoded `provider != "claude"` reject. helper/remote_agent.py:836 had a hardcoded `provider="claude"` in `_local_start_claude_session_impl`. Net effect: the v1.15 macOS picker offered all three but only Claude actually worked locally; Codex/Gemini selections returned `not_implemented` from the helper. Fix: route the local UDS gate through the same `helper.provider_spawners.get_spawner` registry the remote path uses; plumb `payload['provider']` (default "claude") into `SessionStartParams`. Tests: +2 helper Python (`accepts_codex_provider`, `accepts_gemini_provider`) and the existing `rejects_unknown_provider_with_not_implemented` (renamed from `rejects_non_claude_provider…`) for the truly-unknown case. P1 #2 — iOS Sessions panel hid spawn-failure detail iOSSessionsTab.swift filtered the live event stream to `kind == "stdout" || kind == "stderr"`, dropping `kind=="info"` payloads on the floor. When the helper rejected a spawn (e.g. codex binary not on PATH), the user saw an empty ended session with no actionable detail. Fix: add `latestHelperInfoMessage` accessor + a red-tinted banner in the output panel that surfaces the most recent info-event payload. Spawn-failure messages ("spawn failed: ...") now render inline. Formatter hardening (Codex review risk #2 — fixture coverage): Captured `/tmp/v1.15-fixtures/codex_reply.bin` (codex 0.128.0 full launch) and `gemini_reply.bin` (gemini 0.38.2 full launch). Real chrome the v1 formatters missed: Codex: * box-bracketed welcome banner rows (`│ >_ OpenAI Codex … │`) * `Booting MCP server: …(0s • esc to interrupt)` * `Starting MCP servers (1/2): … (0s• esc to inerrupt)` (sic — codex 0.128.0 has the typo `inerrupt`) * `tab to queue message`, `100% context left` * `Tip: Try the Codex App. …` Gemini: * box-bracketed banner rows (parity with codex) * `Gemini CLI v0.38.2`, `Signed in with Google /auth`, `Plan: Gemini Code Assist in Google One AI Pro /upgrade` * `Gemini CLI update available!`, `Installed via Homebrew` * `? for shortcuts`, `Shift+Tab to accept edits` * `> Type your message or @path/to/file` (empty input placeholder — must NOT count as user prompt) * Block-element separator strips (▀▀▀▀ / ▄▄▄▄, U+2580/U+2584 now included in `isBoxDrawingDominant`) Tests: +9 codex, +9 gemini regression cases pinned against the real-fixture content. Validation: * Swift CLIPulseCore: 1029 tests, 0 failures (was 1010, +19) * Helper Python: 87 tests, 0 failures (was 85, +2) * iOS Debug build: BUILD SUCCEEDED * macOS Debug build: BUILD SUCCEEDED Backend contract verification (Codex review risk #1): Read prod via Supabase MCP; both check constraints + the rewritten `remote_app_request_session_start` body match the migration spec. v_provider strict allowlist, INSERT uses v_provider not literal, defaults preserved. No back-compat regression for v1.13/v1.14 clients (they always passed p_provider: "claude" explicitly). Stack ordering (Codex review risk #5): PR #42 IS stacked on PR #41 (branched from dashboard-parity-and-v1.14-lifetime at d5d4cdd). #42 must merge AFTER #41. Documenting in PR comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex independent review of PR #42 caught two P1 blockers and one fixture-coverage gap. This commit lands the fixes. P1 #1 — local UDS macOS path rejected codex/gemini providers helper/local_session_server.py:828 had a hardcoded `provider != "claude"` reject. helper/remote_agent.py:836 had a hardcoded `provider="claude"` in `_local_start_claude_session_impl`. Net effect: the v1.15 macOS picker offered all three but only Claude actually worked locally; Codex/Gemini selections returned `not_implemented` from the helper. Fix: route the local UDS gate through the same `helper.provider_spawners.get_spawner` registry the remote path uses; plumb `payload['provider']` (default "claude") into `SessionStartParams`. Tests: +2 helper Python (`accepts_codex_provider`, `accepts_gemini_provider`) and the existing `rejects_unknown_provider_with_not_implemented` (renamed from `rejects_non_claude_provider…`) for the truly-unknown case. P1 #2 — iOS Sessions panel hid spawn-failure detail iOSSessionsTab.swift filtered the live event stream to `kind == "stdout" || kind == "stderr"`, dropping `kind=="info"` payloads on the floor. When the helper rejected a spawn (e.g. codex binary not on PATH), the user saw an empty ended session with no actionable detail. Fix: add `latestHelperInfoMessage` accessor + a red-tinted banner in the output panel that surfaces the most recent info-event payload. Spawn-failure messages ("spawn failed: ...") now render inline. Formatter hardening (Codex review risk #2 — fixture coverage): Captured `/tmp/v1.15-fixtures/codex_reply.bin` (codex 0.128.0 full launch) and `gemini_reply.bin` (gemini 0.38.2 full launch). Real chrome the v1 formatters missed: Codex: * box-bracketed welcome banner rows (`│ >_ OpenAI Codex … │`) * `Booting MCP server: …(0s • esc to interrupt)` * `Starting MCP servers (1/2): … (0s• esc to inerrupt)` (sic — codex 0.128.0 has the typo `inerrupt`) * `tab to queue message`, `100% context left` * `Tip: Try the Codex App. …` Gemini: * box-bracketed banner rows (parity with codex) * `Gemini CLI v0.38.2`, `Signed in with Google /auth`, `Plan: Gemini Code Assist in Google One AI Pro /upgrade` * `Gemini CLI update available!`, `Installed via Homebrew` * `? for shortcuts`, `Shift+Tab to accept edits` * `> Type your message or @path/to/file` (empty input placeholder — must NOT count as user prompt) * Block-element separator strips (▀▀▀▀ / ▄▄▄▄, U+2580/U+2584 now included in `isBoxDrawingDominant`) Tests: +9 codex, +9 gemini regression cases pinned against the real-fixture content. Validation: * Swift CLIPulseCore: 1029 tests, 0 failures (was 1010, +19) * Helper Python: 87 tests, 0 failures (was 85, +2) * iOS Debug build: BUILD SUCCEEDED * macOS Debug build: BUILD SUCCEEDED Backend contract verification (Codex review risk #1): Read prod via Supabase MCP; both check constraints + the rewritten `remote_app_request_session_start` body match the migration spec. v_provider strict allowlist, INSERT uses v_provider not literal, defaults preserved. No back-compat regression for v1.13/v1.14 clients (they always passed p_provider: "claude" explicitly). Stack ordering (Codex review risk #5): PR #42 IS stacked on PR #41 (branched from dashboard-parity-and-v1.14-lifetime at d5d4cdd). #42 must merge AFTER #41. Documenting in PR comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* plan: v1.15 multi-CLI managed sessions (Codex + Gemini)
Extending the existing managed-Claude-session control loop to also
spawn Codex CLI and Gemini CLI on the paired Mac. Same pairing, same
streaming, same conversation preview UX. Provider abstraction at the
helper level + per-provider TUI formatters.
Scope explicitly defers:
- terminal emulator / 1:1 TUI fidelity (permanently)
- approval-hook protocol for Codex/Gemini (until upstream supports it)
- other CLIs (Q, Cursor, Aider, Copilot CLI) until demand surfaces
Reviewed by Gemini 3.1 Pro (SHIP verdict). Two findings to address in
Phase 2: P1 audit of RLS policies for hardcoded provider lists before
the gemini check-constraint migration; P2 ALTER TABLE lock window
(microseconds for our table size, acknowledged).
6 phases, ~12-15 dev days estimated. See plan for breakdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 phase 1: provider-spawner registry (Claude/Codex/Gemini)
Helper-side abstraction so adding a new managed-session provider is
one file (`helper/provider_spawners/<name>.py`) rather than scattering
argv resolution + capability detection across `RemoteAgentManager`.
Why now: previous design had `CLAUDE_ARGV = ["claude"]` and
`_argv_for(provider)` hardcoded to reject anything other than Claude.
Multi-CLI managed sessions (per PROJECT_PLAN_v1.15_*) extend the
control loop to Codex CLI and Gemini CLI, so the spawn site must be
provider-agnostic.
## What's in this commit
* `helper/provider_spawners/__init__.py` — `ProviderSpawner` protocol
+ `_REGISTRY` dict + `get_spawner(name)` / `available_providers()` /
`all_provider_names()` accessors.
* `helper/provider_spawners/claude.py` — extracted from the inline
`CLAUDE_ARGV` path. Adds `CLI_PULSE_CLAUDE_ARGV0` env override for
users with non-PATH installs.
* `helper/provider_spawners/codex.py` — argv `["codex"]`. Approval
surface explicitly false (no first-class hook in v1.15).
* `helper/provider_spawners/gemini.py` — argv `["gemini"]`, with
opt-in `--yolo` when `params.extra_env["CLI_PULSE_GEMINI_YOLO"]` is
set. Approval surface false.
* `RemoteAgentManager._argv_for` retained as a backwards-compat
alias; new code uses `_spawner_for`. Legacy fallback for test rigs
that can't import the package.
* `helper/test_provider_spawners.py` — 19 new tests covering
registry shape, argv resolution, env overrides, ARGV0 overrides,
`--yolo` opt-in semantics, approval-surface contract,
is_available() with stub PATH.
* `helper/test_remote_agent.py` — renamed
`test_dispatch_start_rejects_non_claude_provider` to
`test_dispatch_start_rejects_unknown_provider` (now uses a
bogus name); added two new tests pinning that codex + gemini are
accepted with the right argv shape.
## Tests
`pytest helper/` — 434/434 pass (was 413 + 21 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 phase 2: backend accepts codex + gemini providers (migrate_v0.45)
Applied to production via Supabase MCP in two parts (default-arg
preservation forced a split commit on the helper RPCs).
## What this changes
* `remote_sessions_provider_check`: added `'gemini'`. Constraint now
allows `claude / codex / gemini / shell`.
* `remote_permission_requests_provider_check`: added `'gemini'`.
Forward-compat — v1.15 still ships no remote-approve surface for
non-Claude providers, but the table shouldn't reject if a future
spawner does emit one.
* `remote_app_request_session_start`: removed two Claude hardcodings.
- precondition `if p_provider <> 'claude' raise` → now accepts
`claude / codex / gemini`.
- INSERT now uses the requested `v_provider` (was literal `'claude'`,
which would have silently overwritten codex/gemini requests as
claude rows).
* `remote_helper_register_session`: whitelist extended to include
`gemini`.
* `remote_helper_create_permission_request`: whitelist extended to
include `gemini`.
## Why this is safe
Audit done before applying:
* No RLS policy on `public.*` hardcodes a provider list (verified via
`pg_policies` query; zero matches).
* All four `RETURNS jsonb` RPCs survive `CREATE OR REPLACE` per
`feedback_gemini_review_patterns.md` rule #1 (signature unchanged).
* First apply attempt failed with `42P13: cannot remove parameter
defaults` because I omitted `default ''::text` etc. on the helper
RPCs. Second apply preserved every default verbatim from the
pre-existing function signatures.
## Verification
```
SELECT pg_get_constraintdef(oid) FROM pg_constraint
WHERE conname IN ('remote_sessions_provider_check',
'remote_permission_requests_provider_check');
-- Both now list 'gemini'.
SELECT proname, prosrc LIKE '%''gemini''%' AS has_gemini,
prosrc LIKE '%p_provider <> ''claude''%' AS still_hardcodes_claude
FROM pg_proc WHERE proname IN (...);
-- has_gemini=true, still_hardcodes_claude=false for all three.
```
## Reviews
* Gemini 3.1 Pro: ship verdict. Concerns addressed inline above
(`shell` left in remote_sessions check by design — table is the
superset; app-facing RPC enforces the v1.15 subset of
`claude/codex/gemini`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 phase 3: per-provider conversation preview (Codex/Gemini formatters + router)
Adds Codex and Gemini conversation preview formatters and a router
that dispatches by `RemoteSession.provider`. Wires the router into
both iOS and macOS Sessions tabs so a managed Codex / Gemini session
gets its own marker recognition and TUI chrome filter, replacing the
hardcoded `ClaudeConversationPreviewFormatter` call sites.
Codex formatter:
* `›` (U+203A) marker for user-prompt echo
* No assistant marker — falls back to 8+ alnum prose heuristic
* Update-wizard chrome dropped (Codex paints with CUP, no newlines,
so substring-match the entire concatenated mega-line)
* Real-fixture regression test from `/tmp/v1.15-fixtures/codex_hello.bin`
Gemini formatter:
* `>` marker for user-prompt echo
* Trust-folder wizard, "Waiting for authentication", "Ready (cwd)"
banners filtered as chrome
* Real-fixture regression test from `gemini_banner.bin`
Router:
* Provider-keyed dispatch with case-insensitive normalize
* Unknown providers fall back to Claude formatter (history bias —
every pre-v1.15 row is `claude`)
* `headerLabel(for:)` / `emptyFallback(for:)` for UI rendering
iOS + macOS Sessions tab:
* `transcript` now goes through `ConversationPreviewRouter.format`
* Header text "<Provider> conversation preview"
* Empty-fallback comparison uses provider-keyed string
Tests: +65 (28 Codex + 27 Gemini + 10 Router). Full CLIPulseCore
suite 1008 tests, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 phase 4: iOS / macOS spawn picker (Claude / Codex / Gemini)
Adds a provider picker to the macOS Sessions tab and iOS Sessions tab
"New" button so the user can pick which CLI to spawn. Plumbs the
choice end-to-end:
iOS / macOS Menu
→ openManagedClaudeSession(provider:)
→ DataRefreshManager.requestRemoteClaudeSessionStart(provider:)
→ APIClient.remoteRequestSessionStart(provider:)
→ SQL `remote_app_request_session_start(p_provider: ...)`
macOS local fast path
→ LocalSessionControlState.requestLocalClaudeSessionStart(provider:)
→ LocalSessionControlClient.startManagedSession(provider: ...)
→ helper UDS `start_session` RPC
Plumbing changes:
* `APIClient.remoteRequestSessionStart`: new `provider` param,
default "claude" for back-compat with pre-v1.15 call sites.
* `DataRefreshManager.requestRemoteClaudeSessionStart`: same.
* `LocalSessionControlState.requestLocalClaudeSessionStart`: same.
* `SessionControlClient` protocol: `startClaudeSession` retired in
favor of `startManagedSession(provider: ...)`. Legacy
`startClaudeSession(...)` preserved as a default-implemented
extension that forwards `provider: "claude"` so old call sites
and any third-party stubs keep building.
* `RemoteSessionControlClient` / `LocalSessionControlClient`:
implement the new method; UDS payload reads provider from caller.
* Test stubs updated to implement `startManagedSession`.
UI:
* macOS: `Button` → `Menu` with three items (Claude / Codex / Gemini).
* iOS: same Menu in `managedSection` and the toolbar.
* Each item has its own SF Symbol so the dropdown is scannable.
Builds: iOS + macOS both BUILD SUCCEEDED. Tests: 1008 pass.
Phase 5 (helper capability advertisement) will gray out unavailable
providers in the Menu by reading the helper's `provider_availability`
heartbeat field; for now we offer all three and surface the helper's
typed error if a binary is missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 phase 5: helper capability advertisement (provider_availability)
The macOS spawn picker now grays out provider menu items for CLIs the
helper can't actually launch, instead of optimistically offering all
three and surfacing a UDS error after the user clicks. Plumbed via a
new `provider_availability` field on the helper UDS `hello` reply.
Helper (Python):
* `local_session_server.py::_handle_method('hello')` now reads
`helper.provider_spawners.available_providers()` (the v1.15
spawner registry's installed-on-this-host check) and includes the
list verbatim. Defensive try/except so a stale import doesn't
break the whole hello reply.
* Test asserts the field is always present, always a list, always
a subset of {claude, codex, gemini}.
Swift transport:
* `SessionControlHello` gains `providerAvailability: [String]`.
Init defaults it to empty so back-compat callers don't break.
* `LocalSessionControlClient.hello()` parses the new field;
omits-treated-as-empty so a pre-v1.15 helper doesn't blank the
picker.
* `RemoteSessionControlClient.hello()` is unchanged — cross-Mac
capability advertisement requires a `devices` table column we
don't ship until v1.16. Leaving the remote path empty means iOS
keeps offering all three providers and any miss surfaces as a
typed helper error.
UI:
* `AppState.localProviderAvailability: [String]` (published).
* `LocalSessionControlState.refreshLocalSessionControlState()`
pipes the hello field into AppState.
* macOS `SessionsTab` Menu disables the Codex / Gemini items when
`state.localProviderAvailability` is non-empty AND the provider
isn't in the list. Empty list (legacy helper) → all three
enabled, same as Phase 4.
Tests:
* Swift: SessionControlHello round-trip + Equatable parity + default
empty.
* Python: hello reply structural assertion.
* Builds: iOS + macOS both BUILD SUCCEEDED. Suite: 1010 Swift tests,
55 helper Python tests (provider spawners + local session server).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15.0 build 53: version bump + ship checkpoint
Bumps marketing version to 1.15.0 / build 53 across all 5 Info.plists
+ all 10 xcconfig occurrences in `project.pbxproj`. Updates
PDFReportGenerator's defensive fallback to match.
Final ship gate before tag / TestFlight:
* Swift CLIPulseCore: 1010 tests, 0 failures (2 skipped)
* Helper Python: 85 tests across provider_spawners +
local_session_server + remote_agent — all pass
* macOS Release build: BUILD SUCCEEDED
* iOS Release build: BUILD SUCCEEDED
* Backend migrate_v0.45 already applied to prod (Phase 2)
v1.15 ships:
* Multi-CLI managed sessions (Claude / Codex / Gemini)
* Per-provider conversation preview formatters with TUI chrome
filtering + real-fixture regression tests
* iOS / macOS spawn picker with helper capability gray-out
* Helper provider-availability advertisement via UDS hello
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(helper): drop unused `import os` in test_provider_spawners
Ruff lint blocked v1.15 Helper CI on PR #42 with F401. The import was
left over from an earlier draft of the spawner test suite that
monkeypatched env vars directly via `os.environ`; the final version
uses pytest's `monkeypatch` fixture instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(helper): use bare imports + relative package imports for CI cwd
Helper CI runs `pytest -q` from the `helper/` working directory, not
from the repo root. The Phase 1 / 5 commits used `from helper.X
import ...` paths in four places, which only resolve when the repo
root is on PYTHONPATH:
* helper/provider_spawners/__init__.py:31-33
* helper/remote_agent.py:91 (lazy import in `_known_provider_names`)
* helper/remote_agent.py:962 (lazy import in `_spawner_for`)
* helper/local_session_server.py (Phase 5 hello-reply addition)
* helper/test_provider_spawners.py top-level import
Switched all five sites to:
* relative imports (`from .claude import ClaudeSpawner`) inside the
spawner package
* bare imports (`from provider_spawners import ...`) outside, with
the same `sys.path.insert(HELPER_DIR)` trick `test_local_session_
server.py` already uses for `local_executor` / `local_session_
server`
Tests pass from both the repo root (`pytest helper/...`) and from
inside `helper/` (`cd helper && pytest`); CI uses the latter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 hardening (Codex review pass): fix 2 P1s + tighten formatters
Codex independent review of PR #42 caught two P1 blockers and one
fixture-coverage gap. This commit lands the fixes.
P1 #1 — local UDS macOS path rejected codex/gemini providers
helper/local_session_server.py:828 had a hardcoded
`provider != "claude"` reject. helper/remote_agent.py:836 had a
hardcoded `provider="claude"` in `_local_start_claude_session_impl`.
Net effect: the v1.15 macOS picker offered all three but only
Claude actually worked locally; Codex/Gemini selections returned
`not_implemented` from the helper.
Fix: route the local UDS gate through the same
`helper.provider_spawners.get_spawner` registry the remote path
uses; plumb `payload['provider']` (default "claude") into
`SessionStartParams`.
Tests: +2 helper Python (`accepts_codex_provider`,
`accepts_gemini_provider`) and the existing
`rejects_unknown_provider_with_not_implemented` (renamed from
`rejects_non_claude_provider…`) for the truly-unknown case.
P1 #2 — iOS Sessions panel hid spawn-failure detail
iOSSessionsTab.swift filtered the live event stream to
`kind == "stdout" || kind == "stderr"`, dropping `kind=="info"`
payloads on the floor. When the helper rejected a spawn (e.g.
codex binary not on PATH), the user saw an empty ended session
with no actionable detail.
Fix: add `latestHelperInfoMessage` accessor + a red-tinted banner
in the output panel that surfaces the most recent info-event
payload. Spawn-failure messages ("spawn failed: ...") now render
inline.
Formatter hardening (Codex review risk #2 — fixture coverage):
Captured `/tmp/v1.15-fixtures/codex_reply.bin` (codex 0.128.0
full launch) and `gemini_reply.bin` (gemini 0.38.2 full launch).
Real chrome the v1 formatters missed:
Codex:
* box-bracketed welcome banner rows (`│ >_ OpenAI Codex … │`)
* `Booting MCP server: …(0s • esc to interrupt)`
* `Starting MCP servers (1/2): … (0s• esc to inerrupt)` (sic —
codex 0.128.0 has the typo `inerrupt`)
* `tab to queue message`, `100% context left`
* `Tip: Try the Codex App. …`
Gemini:
* box-bracketed banner rows (parity with codex)
* `Gemini CLI v0.38.2`, `Signed in with Google /auth`,
`Plan: Gemini Code Assist in Google One AI Pro /upgrade`
* `Gemini CLI update available!`, `Installed via Homebrew`
* `? for shortcuts`, `Shift+Tab to accept edits`
* `> Type your message or @path/to/file` (empty input
placeholder — must NOT count as user prompt)
* Block-element separator strips (▀▀▀▀ / ▄▄▄▄, U+2580/U+2584
now included in `isBoxDrawingDominant`)
Tests: +9 codex, +9 gemini regression cases pinned against the
real-fixture content.
Validation:
* Swift CLIPulseCore: 1029 tests, 0 failures (was 1010, +19)
* Helper Python: 87 tests, 0 failures (was 85, +2)
* iOS Debug build: BUILD SUCCEEDED
* macOS Debug build: BUILD SUCCEEDED
Backend contract verification (Codex review risk #1):
Read prod via Supabase MCP; both check constraints + the
rewritten `remote_app_request_session_start` body match the
migration spec. v_provider strict allowlist, INSERT uses
v_provider not literal, defaults preserved. No back-compat
regression for v1.13/v1.14 clients (they always passed
p_provider: "claude" explicitly).
Stack ordering (Codex review risk #5): PR #42 IS stacked on PR
#41 (branched from dashboard-parity-and-v1.14-lifetime at
d5d4cdd). #42 must merge AFTER #41. Documenting in PR comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 hardening round 2 (Codex review pass): 2 P1 + 2 P2 + doc fix
Round-2 review of the v1.15 diff (d5d4cdd..31095be) caught real bugs
my first hardening pass missed. All four fixes verified by reading
the cited code, then patched + tested.
[P1] LocalSessionControlClient ignored helper's `ok: false`
helper/remote_agent.py:882 returns
{"session_id": <uuid>, "ok": <spawn_succeeded?>}
but Swift only checked `session_id` existed, then
LocalSessionControlState.requestLocalClaudeSessionStart
optimistically appended a "running" row. A failed spawn (e.g. codex
binary not on PATH) showed up in the UI as a healthy session that
silently never produced output.
Fix:
* SessionControlClient: new `.spawnFailed(detail:)` error case
with synthesized Equatable + clear `description`.
* LocalSessionControlClient.startManagedSession: throws
.spawnFailed when `result["ok"] as? Bool == false`.
* LocalSessionControlState's existing `catch` branch already
sets `localHelperError` and skips the optimistic append, so
the throw flows through cleanly.
* +2 SessionControlClientTests (description + Equatable
distinctness from .notImplemented).
[P1] iOS spawn-failure banner was unreachable
iOSSessionsTab.swift gated event polling AND the banner itself
inside `if showOutput`. `showOutput` is a privacy opt-in default-
off — so a cold-start failure (user clicks Codex on a Mac without
codex installed) showed up as an empty ended session with no
actionable detail.
Fix:
* Move the `latestHelperInfoMessage` banner OUT of `outputPanel`
to live above the divider, always rendered when an info event
exists.
* Add a one-shot fetch in the `.task` loop: when the user lands
on the detail view OR when the session has reached its
terminal state, fetch events once even if showOutput=false.
Polling stays gated (privacy stance unchanged).
[P2] macOS picker empty-availability semantics inverted
SessionsTab.swift treated empty `localProviderAvailability` as
"all three providers OK" — but the only way to legitimately
observe an empty list is a pre-v1.15 helper, which only ever
supported Claude. The buggy fallback gray-baited users into
clicking Codex/Gemini items the helper would refuse.
Fix: rewrote the gate to a 3-branch IIFE (SwiftUI ViewBuilder
rejects inline if/else assignment):
* Cross-Mac (`!canStartLocal`) → all three optimistic
(no per-Mac map yet, deferred to v1.16 column).
* Local + empty advertised list → Claude-only.
* Local + advertised list → contains-membership.
[P2] macOS info-event banner parity with iOS
SessionsTab.swift's outputPanel filtered to stdout/stderr only,
same bug as iOS. Surface `latestInfoMessage` as a small banner
above the transcript header. Local-routed rows skip the banner —
their info events come from a different stream (the broker), and
rendering the helper-event one would be misleading.
[P3] gemini.py YOLO docstring described a non-existent UI
Old docstring claimed "the iOS spawn picker surfaces an opt-in
'Auto-approve all tools (yolo)' toggle". No such toggle exists.
The wire (`extra_env['CLI_PULSE_GEMINI_YOLO']`) is implemented in
the spawner so it's ready when the UI lands. Rephrased docstring
to "Future picker work (NOT in v1.15)" + "Until that UI ships,
the env var is never set by any caller and Gemini runs in
default mode" so future readers don't think it's already shipped.
Validation:
* Swift CLIPulseCore: 1031 tests, 0 failures (was 1029, +2 from
spawnFailed unit tests).
* Helper Python: 436 tests pass (full helper/ suite).
* iOS Debug build: BUILD SUCCEEDED.
* macOS Debug build: BUILD SUCCEEDED (after IIFE rewrite for the
SwiftUI ViewBuilder constraint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 hardening round 3 (Codex review): macOS one-shot info fetch + iOS dedup
Two more findings from Codex's round-3 review of the v1.15 diff. Both
verified by reading the cited code first.
[P2] macOS info banner still gated behind showOutput
Round-2 added a kind=='info' banner to macOS parity with iOS, but
put it INSIDE outputPanel — same gating bug iOS had pre-round-2.
And the macOS sessions tab only fetches remote events when
showOutput=true (line 77). So a remote-routed Codex/Gemini spawn
failure was invisible unless the user happened to expand Show
output for that specific row. macOS makes this worse than iOS:
errored rows can drop off the active list before the user thinks
to expand them.
Fix:
* Move the info banner OUT of outputPanel and into the
`commandBar(for:)` body so it always renders for the row,
regardless of showOutput.
* Attach `.task(id: session.status)` to the row VStack: when the
row transitions to a terminal state (errored / stopped /
ended) AND the event cache is empty AND the row is
remote-routed, do a one-shot `refreshRemoteSessionEvents`.
`id:` keying means the .task re-fires on status transition
even if the row stays mounted.
* Local-routed rows skip the banner: their info path is the
broker, not the Supabase tail, and the broker-side info
events don't surface here yet.
[P3] iOS info banner duplicated
Round-2 added a NEW outer banner above the showOutputToggle
(line 672) but didn't remove the OLD inner banner inside
outputPanel (line 888). When the user opened Show output they
saw two copies of the same spawn-failure info.
Fix: remove the inner copy. Banner only renders in the parent
body now, regardless of toggle state.
Validation:
* Swift CLIPulseCore: 1031 tests, 0 failures (unchanged — round-3
is pure UI logic, no new error cases).
* macOS Debug build: BUILD SUCCEEDED.
* iOS Debug build: BUILD SUCCEEDED.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 round 4: provider-aware row labels / icons / colors / headers
User report (2026-05-08): on iPhone, codex / gemini managed sessions
all rendered as "Claude on CLI Pulse Helper" with the orange brain
glyph + Claude tint, and the section header still said "Managed
Claude sessions". Multi-CLI is real on the wire but the rendering
layer was untouched — every visible label was hardcoded to Claude.
New `ProviderDisplay` namespace centralizes per-provider strings:
* `displayName(for:)` — "Claude" / "Codex" / "Gemini"
* `iconSymbol(for:)` — sparkles / chevron-slash / diamond
(matches the spawn picker's per-item icons
so the same shape carries through)
* `color(for:)` — delegates to PulseTheme.providerColor with
the canonical name
* `defaultLabel(for:)`— "<Provider> session" nil-fallback
* `managedSectionHeader` — "Managed sessions" (provider-agnostic)
iOS:
* managedRow icon, color, label fallback now provider-aware
* managedSection header uses the new constant
* remoteSessions list (lower section) icon + label fallback
* detail-view header (header) full re-tint
* navigationTitle nil-fallback
* card overlay border tint
* openManagedClaudeSession picker now stores
`"<Provider> on <device>"` as the row's client_label so the
stored data carries the choice end-to-end (pre-fix the iOS
picker stored just the device name, which is why the user's
screenshot showed "Claude on CLI Pulse Helper" for a Codex
spawn — the renderer fell back to Claude defaults on a label
that didn't say "Codex" anywhere)
macOS:
* SessionRow.displayLabel uses provider name in "<Provider> on
<device>" collapse + nil-fallback
* SessionRow icon + tint provider-aware
* detectedSessionRow nil-fallback respects detected provider
* Section header constant
Validation: Swift 1031 tests, 0 failures. iOS + macOS Debug builds
BUILD SUCCEEDED.
NOTE on user's pending-stuck issue (codex / gemini session never
flips to running): root cause is the helper PROCESS, not v1.15
code. The user is running the App Store v1.14 helper on their Mac;
that helper doesn't know how to spawn codex / gemini and rejects
the start command. v1.15 code on disk is correct (verified by
spawner-registry tests + the prod migration). Helper needs to be
upgraded to v1.15 — same merge-and-ship blocker the rest of the PR
has been waiting on. Documented separately in PR #42 comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: harden multi-cli managed session startup
* v1.15 Path C: Swift HelperKit gains multi-CLI spawner registry
Path C from the user's choice 2026-05-09: port the Python helper's
ProviderSpawner registry to Swift HelperKit so when Phase 4D LaunchAgent
flips on (in v1.16+ once entitlements are fixed) the Swift helper is
already multi-CLI native — no second migration.
New `HelperSwift/Sources/HelperKit/ProviderSpawners/`:
* `ProviderSpawner.swift` — protocol mirroring Python's
helper/provider_spawners/__init__.py. Default extension methods
cover `defaultArgv0()` (CLI_PULSE_<NAME>_ARGV0 tokenized override)
and `defaultIsAvailable()` (override file OR PATH lookup).
* `ClaudeSpawner.swift` — preserves Phase 4D iter10 inline-settings
hook injection (`claude --settings <json>`) via an injectable
`buildInlineSettings` closure. The standard registry wires it to
`ManagedSessionManager.buildInlineSettingsForManagedSession`, so
structured approvals continue to fire.
* `CodexSpawner.swift` — argv `[codex]`, no hook protocol,
`supportsRemoteApproval() = false`.
* `GeminiSpawner.swift` — argv `[gemini]` + optional `--yolo` when
`extraEnv['CLI_PULSE_GEMINI_YOLO']` is truthy. Truthy parser
table identical to Python (`1`/`true`/`yes` case-insensitive).
* `ProviderSpawnerRegistry.swift` — registry + `spawner(for:)`
case-insensitive lookup + `availableProviderNames()` for the
UDS hello reply.
`ManagedSessionManager.startSession` now routes through the registry:
* Replaced the hardcoded `switch provider { case "claude": ...
default: throw }` block with `providerRegistry.spawner(for:)` →
`argv(extraEnv:helperArgv0:)`.
* Standard registry is wired in the default init so existing
callers that don't pass a custom registry get the multi-CLI
behaviour automatically. Tests can pass `providerRegistry:`
explicitly to stub spawners.
* New `availableProviders()` accessor exposes the registry's
snapshot to `LocalSessionServer.handleHello`.
`LocalSessionServer` hello reply now ships `provider_availability:
[String]` populated from the manager's registry. The macOS / iOS
spawn picker already consumes this field (via Codex's d92bb88
version-gate work) — this commit provides the same field on the
Swift helper so both Python and Swift backends advertise the same
shape.
`ENABLE_USER_SCRIPT_SANDBOXING = NO` on the macOS app target. The
Phase 4D "Build Helper Binary (Swift)" build script invokes
`swift build` which reads .git for SwiftPM resolution; project
default `ENABLE_USER_SCRIPT_SANDBOXING = YES` blocked git → swift
build failed → CI macOS build broke. Disabling sandbox on the
target's Debug + Release configs unblocks both local Xcode and CI.
Tests: 15 new in `ProviderSpawnerRegistryTests` (parity with the
Python `test_provider_spawners.py` 19 tests, minus the
monkeypatch-style env override cases which are awkward in Swift
without dependency injection). Full HelperSwift suite: 322 tests,
0 failures.
Validation:
* HelperSwift: 322 tests pass (was 307, +15)
* CLIPulseCore: 1033 tests pass (Codex's d92bb88 added 2)
* macOS Debug build (clean, no pre-built helper): BUILD SUCCEEDED
* iOS Debug build: BUILD SUCCEEDED
Phase 4D entitlements + MAS/Developer-ID distribution path are NOT
fixed by this commit — those are v1.16+ blockers. v1.15 ships with
the Python helper as the runtime backend; the Swift helper is
ready and multi-CLI but only invoked via the LaunchAgent, which
still doesn't run cleanly in MAS-stripped production. Documented
in PR #42 comment chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 ship-blocker fix: bump helper version constants to 1.15.0
User report 2026-05-09: tested v1.15 macOS app via Xcode, picker showed
"Codex and Gemini sessions require CLI Pulse Helper 1.15 or later …
Current helper: 0.1.0" with Codex/Gemini menu items disabled. Verified
against the prod `devices` table: ALL paired Macs have helper_version
< 1.15 (newest was "1.0.0", default-paired Mac was "0.1.0"), so
Codex's d92bb88 version-gate disabled the multi-CLI picker for every
existing user — and would do the same for new users since the pair
default was also 0.1.0.
Three constants needed to bump in lockstep:
1. `helper/cli_pulse_helper.py` — `pair_parser --helper-version`
default: "0.1.0" → "1.15.0". Newly-paired Macs now write 1.15.0
on first heartbeat-after-pair (used to stick at 0.1.0 forever
unless the user passed --helper-version explicitly).
2. `helper/system_collector.py` — `HELPER_VERSION` constant: "0.2.0"
→ "1.15.0". The DeviceSnapshot heartbeat (the daemon's regular
tick) writes this to `devices.helper_version`, so existing paired
Macs running this v1.15 helper get bumped on the next ~120s tick.
3. `HelperSwift/Sources/HelperKit/SystemCollection/SystemCollector.swift`
— `helperVersion` default: "swift-phase4e-slice2d" → "1.15.0".
The Swift helper's heartbeat would have failed the gate even
harder (the placeholder string never parsed as semver via
`firstSemanticVersion`). Once Phase 4E LaunchAgent flips on, the
Swift helper's heartbeats will now correctly mark the device row
as multi-CLI capable.
Tests: HelperSwift 322, CLIPulseCore 1033, helper Python 436 — all
pass. iOS + macOS Debug builds: BUILD SUCCEEDED.
The user's specific iCloud-account device row ID
5157dc27-7af8-4de8-a603-f5aeaa010e1e ("CLI Pulse Helper", type=Mac)
was bumped directly in prod via Supabase RPC so they can keep
testing without waiting for their helper to heartbeat. Other
existing users with stale helper rows get the same fix
automatically as soon as their helper picks up the v1.15 code (or
on first launch after the v1.15 macOS app ships).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 round 5 (UX polish): provider-aware prompt placeholder
User report 2026-05-09: macOS managed-session prompt input still
showed "Prompt for Claude…" placeholder regardless of which CLI was
actually running. Pre-fix the entire managed-sessions feature was
Claude-only so hardcoding made sense; v1.15's multi-CLI picker made
this rendering inconsistent.
`promptPlaceholder(...)` now takes a `provider:` parameter and
returns "Prompt for \(ProviderDisplay.displayName(for: provider))…"
for the running case. Other branches (waiting / send-input
unsupported / pending approval) are unchanged — they're not
provider-specific.
Call site at SessionsTab.swift:719 passes `session.provider` as the
key. iOS Sessions tab already routes through `ProviderDisplay`
(Codex's d92bb88 handled the iOS surface; this commit closes the
macOS gap).
Validation: macOS Debug build BUILD SUCCEEDED, CLIPulseCore 1033
tests pass.
Note on the user-reported `exit_code=101` from a Codex spawn: that
is Codex CLI's own exit code (101 is Rust's default panic exit),
not a v1.15 helper bug. The helper accurately reports whatever the
spawned child returned. Documented as known behavior; investigation
of Codex's specific panic trigger is post-launch work that needs a
reproduction with codex's verbose logs enabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v1.15 round 6 (alert flapping fix): stable cpu-spike alert id
User reported 44+ identical "Device CPU usage is elevated" alerts
piling up in the macOS app. Backend cleanup confirmed the actual
count: 484 duplicate rows for the user's iCloud account, all marked
resolved as part of this fix.
Root cause: `helper/system_collector.py:329` generated a fresh
timestamp-based id every helper tick:
alert_id=f"cpu-spike-{int(datetime.now().timestamp())}"
Backend `helper_sync` RPC UPSERTs alerts on `(id, user_id)`, so a
new id every tick means a new row every tick. Each ~120-second sync
appended yet another duplicate; in 24 hours that compounds to
hundreds.
The Swift `CLIPulseCore/AlertGenerator.swift:67` was already fixed
in iter 2B to use `cpu-spike-{deviceID}` — stable per device, single
row updates as the CPU sample changes. This commit ports the same
fix to the Python helper:
alert_id=f"cpu-spike-{device_key}"
where `device_key` comes from the new `device_id: str | None` param
on `collect_alerts(...)`. Production daemon sync path
(cli_pulse_helper.py:287) passes `config.device_id`; legacy callers
(`inspect` subcommand, dataclass tests) get the `device-self`
fallback (no DB push so collisions are moot).
Side-effect of the previous bug: session-spike and session-long
ids were already stable (`session-spike-{session.session_id}`) so
those didn't flap. Verified.
Gemini 3.1 Pro independent review (per
`feedback_gemini_review_patterns.md` mandate):
✓ Fix correct: single stable row UPSERTs as expected
✓ Fallback safe: only used by non-syncing paths
✓ Resolved-stays-resolved: UPSERT doesn't touch is_resolved,
severity/title/message updates flow through correctly
✓ No other timestamp-pattern instances in the helper
⚠ Pre-existing edge case (NOT introduced by this commit, also
present in Swift parity reference): session-spike-proc-{pid}
silently suppresses a NEW alert if the previous PID-1234 alert
was user-resolved before the OS recycled PID 1234 to an
unrelated high-CPU process. Logged as v1.16 follow-up — fix
would either embed process start_time in the id or refire on
severity change after cooldown.
Backend cleanup (one-shot): all 484 duplicate cpu-spike-* rows for
the user's iCloud account were UPDATE ... is_resolved=true so the
Open tab collapses cleanly; future ticks UPSERT a single row.
Validation: helper Python suite 436 tests pass. No Swift code
touched in this commit (Swift side already had the fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: sync versions to v1.15.0 (iOS ↔ Android)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Three independent threads in one PR. Each is reviewable in isolation.
Migration v0.44 is already applied to production via Supabase MCP.
1. Codex P1 (PR #39 follow-up) — MAS LoginItem sandbox verifier
[
build-appstore.sh](CLI Pulse Bar/scripts/build-appstore.sh) now extracts the LoginItem's signed entitlements viacodesign -dand assertscom.apple.security.app-sandbox=true. Source.entitlementsmade explicit (was relying onENABLE_APP_SANDBOX=YESbuild setting injection). CI verifier inswift-ci.ymlgreps the source file for the same invariant. Defends against ITMS-90296 ifENABLE_APP_SANDBOXever drifts.2. Dashboard parity bug — Hypothesis A confirmed + Codex P1/P2 follow-ups
Root cause confirmed by reading code:
dashboard_summary/provider_summaryused Postgrescurrent_date(server UTC) butCostUsageScannerwritesmetric_datein device-localCalendar.current. At 02:03 CN, server queried May 7 UTC while Mac wrote May 8 CN.Fix: migrate_v0.44_user_tz_today.sql adds optional
p_user_today date default nullparameter to both RPCs. Server usescoalesce(p_user_today, current_date). Backwards compatible (NULL default).Codex P1 follow-up —
SubscriptionManager.shouldPromotetie-break: when user owns BOTH Pro auto-renewable AND Pro Lifetime, on a Pro-rank tie prefer the Lifetime JWS so the server'svalidate-receiptpersistscurrent_period_end = NULL. 5 new pinning tests.Codex P2 follow-up —
provider_summaryupper-bound clamp: in multi-timezone scenarios (Mac in CN UTC+8 + iPhone queried from US Pacific) the iPhone's 7-day / 30-day totals could include CN-tomorrow rows the Mac just wrote. Addedmetric_date <= v_todayclamp.v0.42 → v0.44 reconcile — discovered v0.43 was already applied by the cli-pulse-desktop team, which inadvertently regressed rolling-window math (
'7 days'/'30 days'instead of'6 days'/'29 days', +14% / +3.3% inflation). v0.44 restores correct math AND preserves v0.43'supdated_atprojection AND adds anon REVOKE.Migration applied to production via Supabase MCP.
pg_procconfirms(p_user_today date DEFAULT NULL)shape;has_function_privilegeconfirms anon=false / authenticated=true.iOS Cost Forecast parity bonus: added
forecastSection(_:)to [iOSOverviewTab.swift](CLI Pulse Bar/CLI Pulse Bar iOS/iOSOverviewTab.swift) mirroring the macOS forecast card.3. v1.14 Pro Lifetime IAP foundation
ASC IAP draft
com.clipulse.pro.lifetime(¥128 CNY base, Non-Consumable) wired through SubscriptionManager + paywall surfaces + L10n + edge function. No backend schema change — lifetime row is identifiable viaapple_product_id+ nullcurrent_period_end.4. iOS session 3rd-turn output bug (user report 2026-05-08)
Symptom: turn-1/2 output displays correctly; by the 3rd turn the panel stops updating.
Root cause (Gemini-confirmed): the iOS live-tail ScrollView's auto-scroll trigger was
.onChange(of: events.count). The events buffer is a ring buffer; once it hits cap, new events arrive while old ones evict butcountstays constant. SwiftUI'sonChangedoesn't fire — the user perceives "stopped updating" because auto-scroll froze.Fixes:
events.last?.id(monotonic bigserial; always advances). Same fix on macOSSessionsTab.swift.AppState.remoteSessionEventsCapfrom 200 → 500 (covers ~5 min of typical activity at the helper's 3.5KB / 0.5s flush cadence).emptyFallbackdespite stdout flowing, surface event count + Refresh + "Show raw" toggle. The "Show raw" toggle reveals an ANSI-stripped, unfiltered view (tail to 8KB) so the user can see exactly what's streaming.ClaudeConversationPreviewFormatterTests: 3-turn full conversation, viewport-scroll trace, chrome-flood-with-late-prompt.Test plan
swift test --parallel— all suites pass (lifetime tie-break tests + 3 new multi-turn formatter tests)swift package resolve+swift build— cleanxcodebuildmacOS / iOS / Watch / Widgets — BUILD SUCCEEDEDci_check_date_windows.py+ci_check_rpc_contract.py— both passMISSING_METADATA(specificallyappStoreReviewScreenshot.data: null). Pricing + 175 territories + en-US localization are all set. To unblock: build v1.14.0 binary (bump versions to 1.14.0/52 in 6 plist locations; archive macOS/iOS), run StoreKit Sandbox, screenshot the Pro Lifetime tile in the paywall, upload via ASC API, then submit the IAP.🤖 Generated with Claude Code