Skip to content

Update Live Activity agent counts from hook pushes in the background - #433

Open
o-borovets wants to merge 1 commit into
kitknox:mainfrom
o-borovets:feature/live-activity-push-updates
Open

o-borovets wants to merge 1 commit into
kitknox:mainfrom
o-borovets:feature/live-activity-push-updates

Conversation

@o-borovets

Copy link
Copy Markdown
Contributor

Follow-up to #423. This PR applies the review note from that PR.

What it does

Agent hook pushes from rootshell-notify now update the Live Activity counts while the app is in the background.

How it works

On background entry, the app writes a per-pane agent census to the app group, next to the frozen publish. The type is AgentActivityLedger in RootshellPushKit. The file is live-activity-agents.json.

Each entry stores the push-route keys of one pane:

  • An ordinary pane: its own UUID.
  • A control-mode pane: the canonical tmux server identity plus the server-global pane id.
  • Senders from before the canonical form keep the gateway UUID.

When the notification service extension decrypts an agent push with status blocked, done, or failed, it:

  1. Matches the route against the ledger. It uses the same three tiers as PushNotificationRouter.resolve.
  2. Moves that pane to "needs attention".
  3. Saves the ledger.
  4. Republishes the counts through ActivityKit.

The frozen flag stays set. The extension stamps a new agentPushUpdatedAt. The widget then shows "Updated 14:05" instead of "Updates paused" and keeps its colors.

On the next foreground reconcile, the app deletes the ledger and publishes counts from live detection again.

Design decisions — review these

  1. The PR counts only panes that the app already tracks. A push that matches no ledger entry changes nothing. Agents outside a rootshell pane stay uncounted. The notification itself is not affected. If we counted those agents, the widget would disagree with the sidebar after the app returns.

  2. The hooks report only blocked, done, and failed. A push can move a pane into "needs attention", but never back to "working". The ledger encodes this rule.

  3. The extension drops ambiguous matches. A count on the wrong pane is worse than a stale count.

  4. The extension waits for the ActivityKit update before it completes. The system can suspend the extension right after completion. The wait has a 3-second cap on a DispatchGroup. Thus liveactivitiesd cannot hold the notification.

  5. The wait applies only to agent pushes with one of the three statuses. It also applies only when a ledger exists. A foreground app does no extra work. The same is true for a user with the Coding Agents toggle off.

Build and test changes

  • SessionActivityAttributes.swift is now part of the PushNotificationService target. I added a new exception to the project for this. The widget target already uses the same mechanism.
  • The ledger type and the route matching live in RootshellPushKit. The extension already links this package.
  • Unit tests in the package cover: route tiers, case-insensitive UUIDs, idempotent apply, store expiry after 24 h.

Storage

  • The ledger reads and writes use PushConfiguration.appGroup. The China build therefore gets its own group.
  • The code follows the PushSharedState discipline: atomic writes, no file coordination. The code ignores failures.
  • The app writes off the main thread, from cached entries.
  • The background edge does no registry walk and no ActivityKit lookup.

@kitknox

kitknox commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Did you test this on device? I am not observing the live activity update from a push notification in any manual tests yet. I know the OS scheduling of updates isn't exactly application controlled so that may be a part of it.

@o-borovets

Copy link
Copy Markdown
Contributor Author

Yes, I tested it and received notifications that updated the Live Activity, but it seems like that was some kind of edge case, since now I can't consistently reproduce it

I'll keep investigating

@kitknox

kitknox commented Sep 9, 2026

Copy link
Copy Markdown
Owner

@o-borovets

Copy link
Copy Markdown
Contributor Author

Follow-up on the question about the device. I could not reproduce an update either, so I went to find out why. What I found is below, along with a replacement design and the parts I cannot do alone.

What we found

I read the ActivityKit documentation and the forums, then added a probe to the widget extension. The probe logs Activity<SessionActivityAttributes>.activities on every render. While rendering its own activity it got an empty array every time. That includes the frozen-state publish after the background edge. The same report from a device: https://developer.apple.com/forums/thread/735382. Apple documents Activity.update only from the app.

That is why this PR does nothing. LiveActivityAgentUpdater runs in the notification service extension, looks up the activity, finds nothing and returns. update returns Void, so there is no error to log. The banner works, the counts never move. The Simulator cannot show this. simctl push bypasses the extension, and an ad-hoc signature cannot carry aps-environment. I proved it on the widget process instead.

VPNLiveActivityUpdater on main has the same problem. ConnectVPNWidgetIntent and DisconnectVPNWidgetIntent are plain AppIntents with openAppWhenRun = false. They run in the widget extension and get the same empty list. A LiveActivityIntent in the app target executes in the app and would fix it.

I checked the alternatives too. None of them work. An ActivityKit push replaces the whole content-state, and the system decodes it as plain JSON. One encrypted event cannot patch the previous state. The widget cannot write during rendering, so it cannot accumulate events. content-available is throttled and stops after force quit. PROTOCOL.md excludes it for the dedupe race.

Proposed design

The phone keeps aggregating. The relay only triggers a re-render and stores nothing.

  1. The app requests the activity with pushType: .token and writes the token from pushTokenUpdates to the app group. On the background edge it writes the full display snapshot and the agent ledger there too, with a generation number.
  2. The notification service extension decrypts the hook push and matches the route as PushNotificationRouter does. Then it commits the transition to the ledger. The commit succeeds or fails, no unlocked fallback.
  3. After a successful commit the extension asks the relay for a refresh. One HTTPS request under the device credential, with the activity token, the environment and a refresh counter. Bounded, inside the 30 s the extension already has. The banner arrives either way.
  4. The relay forwards it as a liveactivity push to that token, priority 5, with a content-state of a version and the counter. It keeps no state, same as with the APNs token today. A priority 5 push may be delayed, the label shows the time of the last update.
  5. The widget renders from the snapshot and the ledger in the app group. It only reads. Counts, routes and event contents never leave the device.
  6. On activation the app bumps the generation, clears the ledger and publishes from live detection, as today.

The relay sees the activity token per request and when refreshes happen. No counts, routes or tags reach it.

On the client side this means:

  1. LiveActivityManager requests .token and rewrites the token in the app group on every pushTokenUpdates value. Disable removes it.
  2. The snapshot must be the complete display state. A content-state from the relay brings no session, VPN or network data, so the widget needs all of it locally.
  3. The ledger writes atomically and reports failure. Applied event ids stay apart from banner claims, so a repeated push can retry the refresh without changing counts.
  4. The widget gets a tolerant decoder for the marker variant and one display state per presentation. Absent data stays distinct from real zeros. While frozen, working and idle stay muted and attention keeps its emphasis. The label reads Detection paused, updated HH:mm.
  5. The encrypted header gets an occurrence time. Today a delayed push is indistinguishable from a fresh one. thread already identifies the agent run, so the client can drop a stale event by time and thread. Additive, v1 senders keep working.
  6. Two separate fixes: the widget entitlement uses ROOTSHELL_DEFAULT_APP_GROUP while RootshellPushKit reads RootshellAppGroup, so the China build puts the widget in another container. And LiveActivityIntent for the VPN controls.

From this PR the census entries with route keys, AgentActivityLedger with its tests, agentPushUpdatedAt and the Updated HH:mm label stay. The ActivityKit calls in the extension, the wait before the banner and store.clear() on a missed lookup go.

What I cannot do alone

  1. The refresh endpoint lives on push.rootshell.com. One route under Bearer rsd1..., body with token, environment and counter, topic com.kk2.rootshell.push-type.liveactivity, apns-push-type: liveactivity. I can write the client side and the PROTOCOL.md changes, the server side is yours.
  2. Device testing. Push for com.kk2.rootshell only works with your team and your APNs key. My account is a personal team. One detail for your test builds. A Debug build registers with the relay with environment sandbox. The relay has to send those to the APNs sandbox gateway.
  3. NSSupportsLiveActivitiesFrequentUpdates is your call. One refresh per agent event stays far below the hourly limit on a normal day. I would leave it out.

What do you think? If the design works for you, I split this PR into the parts that stay. Then I start on the client side.

@kitknox

kitknox commented Sep 15, 2026

Copy link
Copy Markdown
Owner

I took a look at this today and there are some challenges in making this work with the Apple ActivityKit restrictions here because I have been trying to keep the server side part of this 100% stateless in addition to the end to end encrypted nature of the push notifications. ActivityKit AFAIK cannot write back to the group container, so maintaining the state over time becomes a problem. This is all easy if I break down and actually store state…

If rootshell-notify itself had a full view of state to send along in a notification that would also work, but it doesn’t have this information either. It only knows about a single pane ID.

I appreciate that everything widget related doesn’t drain your battery, but it ends up a nightmare every time.

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.

2 participants