Watch your agents from your phone: turn on push - #189
Merged
Conversation
The app could reach the Mac, get turned away, and never say so. It sat on "Connecting" with the Connect button greyed out, so there was no way to enter the pairing code that would have fixed it. Two causes. The Mac sends its refusal on its own, not as a reply to anything, and the app only listened for refusals when it had just asked to pair. So on a normal launch the message was thrown away, and the app waited fifteen seconds for an answer that was never coming, then started over. It now recognises that message whenever it arrives, and stops instead of retrying, since being unknown to the Mac does not fix itself. Separately, dialling a Mac that is not there had no time limit, so a saved address for something long gone would hang forever. It now gives up after twenty seconds and says it could not reach that Mac. Confirmed working: a real iPhone paired with the bridge running inside the app and is listed on the Mac by name.
Settled how to reach the phone when the app is not open, which is the whole point of the companion and the one thing it still cannot do. Apple ties push credentials to the app, not to the person running it, so there is no way for someone's own Mac to hold its own key for our app. Putting one key inside a public download means anyone can pull it out and send notifications as us. The way out is iCloud. Each person's Mac writes a note to their own private iCloud storage, and their own phone is watching for it. Apple does the delivery. Nothing to hand out, nothing for us to run, and one person's setup cannot touch another's. Looked at how others do it before committing. The closest comparison, Home Assistant, has thousands of self hosted servers and still sends everything through one server they run, in plain text. Another product that advertises no middleman turns out not to wake a sleeping phone at all, which is exactly the problem we are trying to solve. A third claims notifications they cannot read, but the code sends the text in the clear to a third party. So nobody avoids both a middleman and a shared key. The iCloud route does. The lock screen card is demoted to a nice extra. It can only stay current through the least reliable delivery Apple offers, so a plain notification carries the job and the card refreshes when it can. The text says only that an agent needs you; which one stays in the person's own iCloud. Noted as an Apple only bet. An Android companion would need this rebuilt, though the connection itself would carry over.
Adds the plumbing for the phone to be told about a stuck agent when the app is not open, using the person's own iCloud rather than a server we run or a key we hand out. The phone side is done: it watches for a note in the owner's private iCloud, and when one arrives it shows a line on the lock screen without making a sound, and quietly wakes the app to refresh the status card. It also re-reads everything every time you open the app, because these background wakes are throttled, collapsed into one, and dropped entirely if you have swiped the app away. The notification says only that an agent needs you. Which workspace stays in the note in your own iCloud, so nothing meaningful passes through Apple. The Mac side is written but cannot run. Programa is distributed directly rather than through the App Store, and iCloud is one of the permissions Apple only grants through a provisioning profile, which our builds do not carry. Adding it anyway produces an app that signs, notarises, and then refuses to launch. That has happened here before, so it stays switched off behind an explicit flag until the release pipeline can embed a profile. Two things still need a person with the team's Apple account: creating the shared iCloud container and enabling it on both apps. Automatic signing cannot do it, so the phone build for a real device is blocked until then. The simulator build works.
Prepares the phone app for TestFlight so people other than me can actually use it. Adds the privacy declaration Apple has required since iOS 17. Without it an upload is rejected outright. Ours is short because the app collects nothing and tracks nobody: it talks to the owner's own Mac and the owner's own iCloud, and the only thing it remembers is which Mac it is paired with. Also declares that the app uses only standard encryption, which is the exempt case and avoids the export paperwork. Worth a second pair of eyes before the first upload, since that one is a legal statement by the publisher rather than a build setting. Both confirmed present in a built app, not just in the project file. An upload-ready build now comes out clean, signed for distribution.
It installed as "Programa Spike", which was fine while it was a spike and odd on a colleague's home screen. Renames the app and its widget before anyone else installs it, since changing the name after people have it is the annoying way round. Only the displayed name changes. The identifier stays as it is, so the paired phone keeps its pairing.
Groundwork so the Mac side can eventually use iCloud without breaking the app on launch. Apple only grants some permissions, iCloud among them, through a profile that ships inside the app and is checked every single time it starts. We have never shipped one, because nothing we used needed it. Add such a permission without the profile and the app signs, passes Apple's checks, and then refuses to open. That has happened here before. Signing can now embed a profile when one is supplied, and the release build passes one through when the secret exists. With no profile and no secret, which is today, everything behaves exactly as before. Confirmed by signing a real build with the release certificate and no profile: it comes out valid and meets its designated requirement, with no profile inside, same as what ships now. Also adds a small checker that reports what a profile grants and when it expires, and says nothing is wrong when there is no profile at all. Still switched off. Turning iCloud on needs a profile created against the team account, a secret added, and, before any of it merges, launching the finished signed build by hand. Apple's own checks will not catch this failure, only opening the app will.
Your Mac can now tell your phone an agent is blocked while the app is backgrounded. It writes a summary to your own iCloud private database and Apple delivers the notification -- nothing runs on our side, and no key ships in the app. Adds the iCloud entitlement and flips the MobileBridgePush kill switch in the same commit, because they are only safe together: a restricted entitlement without a matching embedded provisioning profile signs and notarizes cleanly and is then killed at launch by AMFI. Requires the APPLE_PROVISION_PROFILE_BASE64 secret, carrying the "Programa Developer ID CloudKit" profile for app ID com.darkroom.programa with container iCloud.com.darkroom.programa.
Two separate bugs, both silent, both found by running the app on a real device rather than by building it. The app could never receive a notification: registerForRemoteNotifications() failed with "no valid aps-environment entitlement string found for application", so CloudKit never got an APNs token. Push Notifications is now enabled on App ID com.darkroom.programa.spike and the entitlement is declared. The background wake was also dead: INFOPLIST_KEY_UIBackgroundModes is not one of the names Xcode synthesises into a generated Info.plist, so the key never reached the built app and iOS logged that "remote-notification" was missing. Every Info.plist key now lives in an explicit info.properties block, because setting info.path turns the INFOPLIST_KEY_* synthesis off entirely and mixing the two styles drops whatever is still expressed the old way. Verified on an iPhone 16 Pro: both runtime errors are gone and the rebuilt profile carries aps-environment plus the iCloud container.
A tab could sit there saying "Claude needs your permission" while that pane was visibly running commands. Answering the prompt cleared nothing, and it never recovered -- the badge stayed lit until the session ended. The hook that clears it resolved the workspace with `try`. When that threw, the enclosing catch treated it as a teardown error, printed "OK", and returned before any of the three clears ran. Claude Code saw a healthy hook, so nothing retried and nothing complained. The sibling surface lookup right below had already been fixed for exactly this reason; this was the other half of it. Nothing else would have caught it either: agent state has no TTL or watchdog, and the screen-detection fallback deliberately refuses to touch a surface a hook has claimed, so a stale blocked state is unrecoverable by design. Resolution is now best-effort, falling back to the workspace the notification hook recorded when it set the blocked state -- that is the workspace that needs clearing, so it is the right thing to use when a live lookup is not available. Only a total absence of any workspace id returns early now, and that case has nothing to clear anyway. No regression test: CLI sources are not in any test target, so covering this needs a test seam rather than a fake assertion over source text.
MobileBridgePush built its CloudKit container the moment the singleton was first touched, which happens before any of the on/off checks inside noteAgentStateChanged get a chance to run. On any build that is not signed with the real provisioning profile, asking for that container kills the process outright, so the app died the first time an agent changed state. That is every debug build and every test machine, which is why the agent state tests and the socket tests were failing. The container is now only built at the point something is actually about to be sent, so builds without the profile just do nothing instead of dying.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Makes the phone companion actually able to tell you an agent is stuck while your Mac is
closed or the app is backgrounded. Your Mac writes a short summary to your own iCloud
private database and Apple delivers the notification — nothing runs on our side, and no
signing key ships inside the app.
Also fixes a desktop bug where a tab kept saying "Claude needs your permission" long after
the prompt was answered. That one matters beyond the sidebar: that state is exactly what
decides whether to send a push, so a stuck badge would have meant a false notification.
Do not merge without launching the notarized build by hand first. See the test plan.
Summary
com.apple.developer.icloud-services/icloud-container-identifierstoprograma.entitlementsand flipsMobileBridgePush.releaseProvisioningCompletein thesame commit. They are only safe together: a restricted entitlement with no matching
embedded profile signs and notarizes cleanly, then gets killed at launch by AMFI.
APPLE_PROVISION_PROFILE_BASE64secret (set), carrying thePrograma Developer ID CloudKitprofile forcom.darkroom.programa.try, andon a throw the catch printed
OKand returned before any of the three clears ran. Nothingrecovers from that — agent state has no TTL, and screen detection refuses to correct a
hook-owned surface.
aps-environment, soregisterForRemoteNotifications()failed outright; andINFOPLIST_KEY_UIBackgroundModesnever reaches the built Info.plist, so the backgroundwake was dead.
Known blocker, not fixed here
Container
iCloud.com.darkroom.programahas noAgentStatusrecord type in eitherenvironment, so CloudKit rejects every query and subscription with
15/2000. Production doesnot auto-create record types — the schema has to be created in Development and deployed.
Left for a human because deploying schema to Production is not casually reversible. Exact
steps and field types are in
plans/golden-tumbling-gray.md.Test plan
does not check entitlements against the embedded profile, only AMFI does, at launch
scripts/verify-provision-profile.sh <app>exits 0 (already passing on the dry-run)