Skip to content

Watch your agents from your phone: turn on push - #189

Merged
arzafran merged 14 commits into
mainfrom
mobile-push-entitlement
Jul 29, 2026
Merged

Watch your agents from your phone: turn on push#189
arzafran merged 14 commits into
mainfrom
mobile-push-entitlement

Conversation

@arzafran

Copy link
Copy Markdown
Member

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

  • Adds com.apple.developer.icloud-services / icloud-container-identifiers to
    programa.entitlements and flips MobileBridgePush.releaseProvisioningComplete in the
    same 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.
  • Requires the APPLE_PROVISION_PROFILE_BASE64 secret (set), carrying the
    Programa Developer ID CloudKit profile for com.darkroom.programa.
  • Fixes the stuck permission badge: the clearing hook resolved its workspace with try, and
    on a throw the catch printed OK and returned before any of the three clears ran. Nothing
    recovers from that — agent state has no TTL, and screen detection refuses to correct a
    hook-owned surface.
  • Fixes two silent iOS bugs found by running on a real device: the app had no
    aps-environment, so registerForRemoteNotifications() failed outright; and
    INFOPLIST_KEY_UIBackgroundModes never reaches the built Info.plist, so the background
    wake was dead.
  • Documents the CloudKit schema blocker (below) and scopes tappable answers.

Known blocker, not fixed here

Container iCloud.com.darkroom.programa has no AgentStatus record type in either
environment, so CloudKit rejects every query and subscription with 15/2000. Production does
not 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

  • Download the dry-run dmg, install, and confirm Programa launches — notarization
    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)
  • Answer a permission prompt and confirm the sidebar badge clears
  • After the schema is deployed: confirm the phone creates its subscription without error

arzafran added 14 commits July 28, 2026 14:28
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.
@arzafran
arzafran merged commit 66e9a6a into main Jul 29, 2026
13 of 16 checks passed
@arzafran
arzafran deleted the mobile-push-entitlement branch July 29, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant