┌─────────────────────────────────────────────────────────────┐
│ 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.
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.
| 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 |
This phase delivers the end-to-end working feature.
Package: mobile-voice
expo-widgets and @expo/ui to dependenciesAdd 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: true and NSSupportsLiveActivitiesFrequentUpdates: true to expo.ios.infoPlist in app.json
Requires a new EAS dev build after this step
New file: src/widgets/session-activity.tsx
SessionActivityProps typeLiveActivityLayout using @expo/ui/swift-ui primitives (Text, VStack, HStack)createLiveActivity('SessionActivity', SessionActivity)LiveActivityEnvironment.colorScheme to handle dark/lightNew file: src/hooks/use-live-activity.ts
Responsibilities:
startActivity(sessionTitle, sessionId) -- calls SessionActivity.start(props, deepLinkURL), stores the instance, gets the push tokenupdateActivity(props) -- calls instance.update(props) for foreground SSE-driven updatesendActivity(finalStatus) -- calls instance.end('default', finalProps)activityPushToken: string | null for relay registrationFile: src/hooks/use-monitoring.ts
useLiveActivitybeginMonitoring(job): call startActivity(sessionTitle, job.sessionID)updateActivity() calls:
session.status busy -> { status: "working", lastMessage: <latest text> }session.status retry -> { status: "retry", retryInfo: "Retry N in Xs" }permission.asked -> { status: "permission", lastMessage: "Needs your decision" }session.status idle (complete) -> endActivity("complete")session.error -> endActivity("error")syncSessionState()File: src/lib/relay-client.ts
New function: registerActivityToken(input) -- calls new relay endpoint POST /v1/activity/register
registerActivityToken(input: {
relayBaseURL: string
secret: string
activityToken: string
sessionID: string
bundleId?: string
}): Promise<void>
New function: unregisterActivityToken(input) -- cleanup
unregisterActivityToken(input: {
relayBaseURL: string
secret: string
sessionID: string
}): Promise<void>
File: src/hooks/use-live-activity.ts or use-monitoring.ts
activityPushToken becomes available after startActivity(), send it to the relay alongside the sessionIDPackage: 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
activity_registration for matching (secret_hash, session_id)apns-push-type: liveactivityapns-topic: {bundleId}.push-type.liveactivitycontent-state containing the SessionActivityProps fieldsevent: "update" for progress, event: "end" for complete/errorNew function in apns.ts: sendLiveActivityUpdate(input)
send() functionliveactivity push type headerscontent-state payload formatFile: packages/opencode/src/server/push-relay.ts
Type union: "complete" | "permission" | "error" | "progress"map() function:
session.status with type: "busy" -> { type: "progress", sessionID }session.status with type: "retry" -> { type: "progress", sessionID } (with retry metadata)message.updated where the message has tool-use or assistant text -> { type: "progress", sessionID } (throttled)notify() / post(): include a contentState object in the relay payload for progress eventsExtend evt payload 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
}
}
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.
File: src/hooks/use-live-activity.ts
addPushToStartTokenListener() from expo-widgets/v1/device/register or new field)Package: apn-relay
push_to_start_token column to device_registration table (nullable)/v1/device/register to accept pushToStartToken fieldNew logic in /v1/event: if eventType is the first event for a session and no activity_registration exists 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"
}
}
}
File: packages/opencode/src/server/push-relay.ts
"start" event typesession.status with type: "busy" (first time for a session) to { type: "start", sessionID }attributes field for push-to-startmobilevoice://session/{id})addPushTokenListener handles this -- forward new tokens to the relay.InvalidToken for an activity token, delete the activity_registration row.SessionActivity.getInstances() to clean up any orphaned activities.| 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) |
expo-widgets docs: https://docs.expo.dev/versions/latest/sdk/widgets/expo-widgets alpha blog post: https://expo.dev/blog/home-screen-widgets-and-live-activities-in-expopackages/apn-relay/src/packages/opencode/src/server/push-relay.tspackages/mobile-voice/src/hooks/use-monitoring.tspackages/mobile-voice/src/lib/relay-client.ts@expo/ui widget components (on Expo's roadmap)