Tighten the dictation UI and Whisper model settings, update the mobile package metadata, and remove the stale npm lockfile so Bun stays the source of truth for builds.
14 KiB
Live Activity Implementation Plan
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ iPhone Lock Screen / Dynamic Island │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Live Activity (expo-widgets) │ │
│ │ "Installing dependencies..." ● Working │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▲ local update (foreground) ▲ APNs push (bg) │
│ │ │ │
│ ┌──────┴─────┐ │ │
│ │ SSE stream │ │ │
│ │ /event │ │ │
│ └──────┬─────┘ │ │
└─────────┼──────────────────────────────┼────────────────────┘
│ │
▼ │
┌──────────────────┐ ┌────────┴─────────┐
│ OpenCode Server │──event──> │ APN Relay │
│ (push-relay.ts) │ │ /v1/activity/* │
│ GlobalBus │ │ apns.ts │
└──────────────────┘ └──────────────────┘
Foreground: App receives SSE events, updates the Live Activity locally via instance.update().
Background: OpenCode server fires events to the relay, relay sends liveactivity APNs pushes with content-state, iOS updates the widget.
Push-to-start: Relay sends a start push to begin a Live Activity even when the app hasn't initiated one.
Content-State Shape
type SessionActivityProps = {
status: "working" | "retry" | "permission" | "complete" | "error"
sessionTitle: string // e.g. "Fix auth bug"
lastMessage: string // truncated ~120 chars, e.g. "Installing dependencies..."
retryInfo: string | null // e.g. "Retry 2/5 in 8s" when status is "retry"
}
This is intentionally lean -- it keeps APNs payload size well under the 4KB limit.
Dynamic Island / Lock Screen Layout
| Slot | Content |
|---|---|
| Banner (Lock Screen) | Session title, status badge, last message text |
| Compact leading | App icon or "OC" text |
| Compact trailing | Status word ("Working", "Done", "Needs input") |
| Minimal | Small status dot/icon |
| Expanded leading | Session title + status |
| Expanded trailing | Time elapsed or ETA |
| Expanded bottom | Last message text, retry info if applicable |
Phase 1: Core Live Activity (App-Initiated, Local + Push Updates)
This phase delivers the end-to-end working feature.
1a. Install and configure expo-widgets
Package: mobile-voice
- Add
expo-widgetsand@expo/uito dependencies - Add the plugin to
app.json:[ "expo-widgets", { "enablePushNotifications": true, "widgets": [ { "name": "SessionActivity", "displayName": "OpenCode Session", "description": "Live session monitoring on Lock Screen and Dynamic Island" } ] } ] - Add
NSSupportsLiveActivities: trueandNSSupportsLiveActivitiesFrequentUpdates: truetoexpo.ios.infoPlistinapp.json - Requires a new EAS dev build after this step
1b. Create the Live Activity component
New file: src/widgets/session-activity.tsx
- Define
SessionActivityPropstype - Build the
LiveActivityLayoutusing@expo/ui/swift-uiprimitives (Text,VStack,HStack) - Export via
createLiveActivity('SessionActivity', SessionActivity) - Adapt layout per slot (banner, compact, minimal, expanded)
- Use
LiveActivityEnvironment.colorSchemeto handle dark/light
1c. Create a Live Activity management hook
New file: src/hooks/use-live-activity.ts
Responsibilities:
startActivity(sessionTitle, sessionId)-- callsSessionActivity.start(props, deepLinkURL), stores the instance, gets the push tokenupdateActivity(props)-- callsinstance.update(props)for foreground SSE-driven updatesendActivity(finalStatus)-- callsinstance.end('default', finalProps)- Manages the per-activity push token lifecycle
- Exposes
activityPushToken: string | nullfor relay registration - Handles edge cases: activity already running (end previous before starting new), activities disabled by user, iOS version checks
1d. Integrate into useMonitoring
File: src/hooks/use-monitoring.ts
- Import and use
useLiveActivity - On
beginMonitoring(job): callstartActivity(sessionTitle, job.sessionID) - In the SSE event handler (foreground): map classified events to
updateActivity()calls:session.statusbusy ->{ status: "working", lastMessage: <latest text> }session.statusretry ->{ status: "retry", retryInfo: "Retry N in Xs" }permission.asked->{ status: "permission", lastMessage: "Needs your decision" }session.statusidle (complete) ->endActivity("complete")session.error->endActivity("error")
- On stop monitoring: end the activity if still running
- On app background: stop SSE (already happens), rely on APNs pushes for updates
- On app foreground: reconnect SSE, sync local activity state with
syncSessionState()
1e. Register activity push tokens with the relay
File: src/lib/relay-client.ts
- New function:
registerActivityToken(input)-- calls new relay endpointPOST /v1/activity/registerregisterActivityToken(input: { relayBaseURL: string secret: string activityToken: string sessionID: string bundleId?: string }): Promise<void> - New function:
unregisterActivityToken(input)-- cleanupunregisterActivityToken(input: { relayBaseURL: string secret: string sessionID: string }): Promise<void>
File: src/hooks/use-live-activity.ts or use-monitoring.ts
- When
activityPushTokenbecomes available afterstartActivity(), send it to the relay alongside thesessionID - On activity end, unregister the token
1f. Extend the APN relay for Live Activity pushes
Package: apn-relay
New endpoint: POST /v1/activity/register
{
secret: string,
sessionID: string,
activityToken: string, // the per-activity push token
bundleId?: string
}
New endpoint: POST /v1/activity/unregister
{
secret: string,
sessionID: string
}
New DB table: activity_registration
id TEXT PRIMARY KEY,
secret_hash TEXT NOT NULL,
session_id TEXT NOT NULL,
activity_token TEXT NOT NULL,
bundle_id TEXT NOT NULL,
apns_env TEXT NOT NULL DEFAULT 'production',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(secret_hash, session_id)
Modified: POST /v1/event handler
- After sending the regular alert push (existing behavior), also check
activity_registrationfor matching(secret_hash, session_id) - If a registration exists, send a second push with:
apns-push-type: liveactivityapns-topic: {bundleId}.push-type.liveactivity- Payload with
content-statecontaining theSessionActivityPropsfields event: "update"for progress,event: "end"for complete/error
New function in apns.ts: sendLiveActivityUpdate(input)
- Separate from the existing
send()function - Uses
liveactivitypush type headers - Constructs
content-statepayload format
1g. Extend the OpenCode server push-relay for richer events
File: packages/opencode/src/server/push-relay.ts
- Extend
Typeunion:"complete" | "permission" | "error" | "progress" - Add cases to
map()function:session.statuswithtype: "busy"->{ type: "progress", sessionID }session.statuswithtype: "retry"->{ type: "progress", sessionID }(with retry metadata)message.updatedwhere the message has tool-use or assistant text ->{ type: "progress", sessionID }(throttled)
- Add to
notify()/post(): include acontentStateobject in the relay payload for progress events - Add throttling: don't send more than ~1 progress push per 10-15 seconds to stay within APNs budget
- Extend
evtpayload sent to relay:{ secret, serverID, eventType, sessionID, title, body, // New field for Live Activity updates: contentState?: { status: "working" | "retry" | "permission" | "complete" | "error", sessionTitle: string, lastMessage: string, retryInfo: string | null } }
Phase 2: Push-to-Start
This lets the server start a Live Activity on the phone when a session begins, even if the user didn't initiate it from the app.
2a. Register push-to-start token from the app
File: src/hooks/use-live-activity.ts
- On app launch, call
addPushToStartTokenListener()fromexpo-widgets - Send the push-to-start token to the relay at registration time (extend existing
/v1/device/registeror new field) - This token is app-wide (not per-activity), so it lives alongside the device push token
2b. Extend relay for push-to-start
Package: apn-relay
- Add
push_to_start_tokencolumn todevice_registrationtable (nullable) - Extend
/v1/device/registerto acceptpushToStartTokenfield - New logic in
/v1/event: ifeventTypeis the first event for a session and noactivity_registrationexists yet, send a push-to-start payload:{ "aps": { "timestamp": 1712345678, "event": "start", "content-state": { "status": "working", "sessionTitle": "Fix auth bug", "lastMessage": "Starting...", "retryInfo": null }, "attributes-type": "SessionActivityAttributes", "attributes": { "sessionId": "abc123" }, "alert": { "title": "Session Started", "body": "OpenCode is working on: Fix auth bug" } } }
2c. Server-side: emit session start events
File: packages/opencode/src/server/push-relay.ts
- Add a
"start"event type - Map
session.statuswithtype: "busy"(first time for a session) to{ type: "start", sessionID } - Include session metadata (title, directory) in the payload so the relay can populate the
attributesfield for push-to-start
Phase 3: Polish and Edge Cases
- Deep linking: When user taps the Live Activity, open the app and navigate to that session (
mobilevoice://session/{id}) - Multiple activities: Handle the case where the user starts multiple sessions from different servers. iOS supports multiple concurrent Live Activities.
- Activity expiry: iOS ends Live Activities after 8 hours. Handle the timeout gracefully (end with a "timed out" status).
- Token rotation: Activity push tokens can rotate. The
addPushTokenListenerhandles this -- forward new tokens to the relay. - Cleanup: When the relay receives an APNs error like
InvalidTokenfor an activity token, delete theactivity_registrationrow. - Stale activities: On app foreground, check
SessionActivity.getInstances()to clean up any orphaned activities.
Changes Per Package Summary
| Package | Files Changed | Files Added |
|---|---|---|
| mobile-voice | app.json, package.json, use-monitoring.ts, relay-client.ts |
src/widgets/session-activity.tsx, src/hooks/use-live-activity.ts |
| apn-relay | src/index.ts, src/apns.ts, src/schema.sql.ts |
(none) |
| opencode | src/server/push-relay.ts |
(none) |
Build Requirements
- New EAS dev build required after Phase 1a (native widget extension target)
- Relay deployment after Phase 1f
- OpenCode server rebuild after Phase 1g
Key Technical References
expo-widgetsdocs: https://docs.expo.dev/versions/latest/sdk/widgets/expo-widgetsalpha blog post: https://expo.dev/blog/home-screen-widgets-and-live-activities-in-expo- Apple ActivityKit push notifications: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
- Existing APN relay:
packages/apn-relay/src/ - Existing push-relay (server-side):
packages/opencode/src/server/push-relay.ts - Existing monitoring hook:
packages/mobile-voice/src/hooks/use-monitoring.ts - Existing relay client:
packages/mobile-voice/src/lib/relay-client.ts
Limitations / Risks
- expo-widgets is alpha (March 2026) -- APIs may break
- Images not yet supported in
@expo/uiwidget components (on Expo's roadmap) - Live Activities have an 8-hour max duration enforced by iOS
- APNs budget: iOS throttles frequent updates; keep progress pushes to ~1 per 10-15 seconds
- NSSupportsLiveActivitiesFrequentUpdates needed in Info.plist for higher update frequency
- Dev builds required -- adding the widget extension is a native change, OTA won't cover it