diff --git a/README.md b/README.md index 3070ce0..ae06976 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ affiliated with the OpenCode team or Anomaly. - [Specification](docs/SPEC.md) - [Deployment configuration](docs/CONFIGURATION.md) - [Self-hosted push notifications](docs/NOTIFICATIONS.md) +- [Enable server push with an agent](docs/PUSH_AGENT_RUNBOOK.md) - [Implementation TODO](TODO.md) ## Architecture @@ -30,7 +31,8 @@ the signed-build workflow and pairing trust model. The app supports saved connections, followed projects, session management, paginated transcripts, text prompts, permission and form responses, encrypted -drafts, and optional self-hosted notifications. It stores Basic or Bearer +drafts, and optional self-hosted permission, form, and session-completion +notifications. It stores Basic or Bearer credentials only in SecureStore. `TODO.md` tracks the remaining product and device-verification work. diff --git a/SECURITY.md b/SECURITY.md index d4c9d09..c8f9d8d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,8 +27,10 @@ networks or encrypted overlays such as Tailscale. Notification pairing codes are two-minute bearer bootstrap secrets. Display a code only in a trusted terminal, scan it immediately, and revoke unexpected -device registrations. Expo, APNs, and FCM receive generic text and encrypted -routing data, not OpenCode credentials. +device registrations. Expo, APNs, and FCM receive text selected from a finite +notification-category allowlist and encrypted routing data, not OpenCode +credentials. Raw permission actions, resources, paths, prompts, form titles, +session titles, errors, and identifiers are excluded from visible text. OpenCode's built-in `/pair` QR is different: it contains the server's actual Basic-auth password. Do not capture, log, or share it. The self-hosted broker's diff --git a/TODO.md b/TODO.md index fa9ae4f..35a6c0f 100644 --- a/TODO.md +++ b/TODO.md @@ -474,7 +474,8 @@ without exposing credentials or depending on undocumented endpoints. - [ ] Add deep links for connection, project, session, file, and pending request. - [ ] Add share-sheet input for text, images, and files. - [ ] Add app quick actions for new session and recent sessions. -- [ ] Add local best-effort completion and blocked-work notifications. +- [ ] Add local best-effort completion and blocked-work notifications. Remote + successful completion is covered by self-hosted push. - [x] Define and implement the self-hosted push architecture. - [ ] Add privacy-mode app-switcher shielding. - [x] Add optional biometric lock. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 2daf94f..40e31b3 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -60,7 +60,7 @@ const config: ExpoConfig = { usesNonExemptEncryption: false, }, infoPlist: { - NSUserNotificationUsageDescription: `${appName} notifies you when an OpenCode session needs a permission or form response.`, + NSUserNotificationUsageDescription: `${appName} notifies you when OpenCode needs a permission or form response, or finishes a session.`, NSLocalNetworkUsageDescription: `${appName} connects to development servers that you approve on your private network.`, NSAppTransportSecurity: { NSAllowsArbitraryLoads: allowDevelopmentHttp, diff --git a/apps/mobile/src/notifications/notification-client.test.ts b/apps/mobile/src/notifications/notification-client.test.ts index 131e0c3..5691a05 100644 --- a/apps/mobile/src/notifications/notification-client.test.ts +++ b/apps/mobile/src/notifications/notification-client.test.ts @@ -25,21 +25,39 @@ test("posts a device command when Hermes does not provide AbortSignal.timeout", value: undefined, }); const fetch = jest.fn(async () => ({ - json: async () => ({ ok: true, operation: "status" }), + json: async () => ({ enabled: true, updatedAtMs: 1_000, v: 1 }), ok: true, })); globalThis.fetch = fetch as unknown as typeof globalThis.fetch; - await sendNotificationDeviceCommand({ - bindingID: "binding-1", - brokerOrigin: "https://push.test", - deviceKey: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", - operation: "status", - }); + await expect( + sendNotificationDeviceCommand({ + bindingID: "binding-1", + brokerOrigin: "https://push.test", + deviceKey: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + operation: "status", + }), + ).resolves.toEqual({ enabled: true, updatedAtMs: 1_000, v: 1 }); expect(fetch).toHaveBeenCalledTimes(1); }); +test("rejects malformed shared notification state", async () => { + globalThis.fetch = jest.fn(async () => ({ + json: async () => ({ enabled: "yes", updatedAtMs: 1_000, v: 1 }), + ok: true, + })) as unknown as typeof globalThis.fetch; + + await expect( + sendNotificationDeviceCommand({ + bindingID: "binding-1", + brokerOrigin: "https://push.test", + deviceKey: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + operation: "status", + }), + ).rejects.toThrow("INVALID_NOTIFICATION_STATE"); +}); + test("prepares the first non-loopback URL from an OpenCode pair QR", () => { expect( prepareOpenCodeDevicePairing({ diff --git a/apps/mobile/src/notifications/notification-client.ts b/apps/mobile/src/notifications/notification-client.ts index 7b8bf6b..fb3bc22 100644 --- a/apps/mobile/src/notifications/notification-client.ts +++ b/apps/mobile/src/notifications/notification-client.ts @@ -13,6 +13,7 @@ import { notificationPushAdditionalData, openNotificationJson, parseNotificationConnectionBootstrap, + parseNotificationDeliveryState, parseNotificationPairingCode, parseNotificationPairingIssueRequest, parseNotificationPairingResponse, @@ -165,7 +166,7 @@ export async function sendNotificationDeviceCommand(input: { operation: input.operation, v: 1, }; - await postJson( + return postJson( `${input.brokerOrigin}/v1/device/${input.operation}`, { bindingID: input.bindingID, @@ -178,7 +179,7 @@ export async function sendNotificationDeviceCommand(input: { nonce: encodeNotificationBytes(nonce), v: 1, }, - parseOk, + parseNotificationDeliveryState, ); } @@ -235,11 +236,6 @@ function assertUsableBroker(code: NotificationPairingCode) { } } -function parseOk(value: unknown) { - if (!isRecord(value) || value.ok !== true) throw new Error("INVALID_BROKER_RESPONSE"); - return value; -} - function isUnusableDeviceHost(hostname: string) { const normalized = hostname .toLowerCase() diff --git a/apps/mobile/src/notifications/notification-pairing-repository.ts b/apps/mobile/src/notifications/notification-pairing-repository.ts index 1558eef..2f4223d 100644 --- a/apps/mobile/src/notifications/notification-pairing-repository.ts +++ b/apps/mobile/src/notifications/notification-pairing-repository.ts @@ -243,6 +243,17 @@ export async function getNotificationPairingByBindingID(db: SQLiteDatabase, bind return row ? decodePairing(row) : undefined; } +export async function getNotificationPairingByConnectionID( + db: SQLiteDatabase, + connectionId: string, +) { + const row = await db.getFirstAsync( + "SELECT * FROM notification_pairings WHERE connection_id = ?", + connectionId, + ); + return row ? decodePairing(row) : undefined; +} + export async function readNotificationPairingSecret(pairing: NotificationPairing) { if (!(await SecureStore.isAvailableAsync())) throw new Error("SECURE_STORE_UNAVAILABLE"); const value = await SecureStore.getItemAsync(pairing.secretRef, notificationSecretStoreOptions); diff --git a/apps/mobile/src/notifications/notification-routing-context.test.ts b/apps/mobile/src/notifications/notification-routing-context.test.ts new file mode 100644 index 0000000..c804a83 --- /dev/null +++ b/apps/mobile/src/notifications/notification-routing-context.test.ts @@ -0,0 +1,29 @@ +import { expect, jest, test } from "@jest/globals"; +import * as Notifications from "expo-notifications"; +import "./notification-routing-context"; + +jest.mock("expo-notifications", () => ({ + addNotificationResponseReceivedListener: jest.fn(), + clearLastNotificationResponseAsync: jest.fn(), + getLastNotificationResponseAsync: jest.fn(), + setNotificationHandler: jest.fn(), +})); + +jest.mock("@opencode2-mobile/opencode-adapter", () => ({ + getOpenCodeSession: jest.fn(), + listOpenCodeFormRequests: jest.fn(), + listOpenCodePermissionRequests: jest.fn(), +})); +jest.mock("../connections/connections-context", () => ({ useConnections: jest.fn() })); +jest.mock("../state/connection-runtime-context", () => ({ useConnectionRuntime: jest.fn() })); + +test("suppresses notification presentation while the app is foregrounded", async () => { + const handler = jest.mocked(Notifications.setNotificationHandler).mock.calls[0]?.[0]; + + await expect(handler?.handleNotification({} as never)).resolves.toEqual({ + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: false, + shouldShowList: false, + }); +}); diff --git a/apps/mobile/src/notifications/notification-routing-context.tsx b/apps/mobile/src/notifications/notification-routing-context.tsx index 0f54da7..b1e9dc1 100644 --- a/apps/mobile/src/notifications/notification-routing-context.tsx +++ b/apps/mobile/src/notifications/notification-routing-context.tsx @@ -27,23 +27,33 @@ import { Notifications.setNotificationHandler({ handleNotification: async () => ({ - shouldPlaySound: true, + shouldPlaySound: false, shouldSetBadge: false, - shouldShowBanner: true, - shouldShowList: true, + shouldShowBanner: false, + shouldShowList: false, }), }); -type PendingRoute = { - bindingID: string; - connectionId: string; - eventID: string; - expiresAtMs: number; - interaction: "form" | "permission"; - location?: { directory: string; workspaceID?: string }; - requestID: string; - sessionID: string; -}; +type PendingRoute = + | { + bindingID: string; + connectionId: string; + eventID: string; + expiresAtMs: number; + interaction: "form" | "permission"; + kind: "interaction"; + location?: { directory: string; workspaceID?: string }; + requestID: string; + sessionID: string; + } + | { + bindingID: string; + connectionId: string; + eventID: string; + expiresAtMs: number; + kind: "session-done"; + sessionID: string; + }; export function NotificationRoutingProvider({ children }: { children: ReactNode }) { const db = useSQLiteContext(); @@ -103,16 +113,28 @@ export function NotificationRoutingProvider({ children }: { children: ReactNode return; } processingEvents.current.add(replayKey); - setPending({ - bindingID: pairing.bindingID, - connectionId: pairing.connectionId, - eventID: route.eventID, - expiresAtMs: route.expiresAtMs, - interaction: route.interaction, - ...(route.location ? { location: route.location } : {}), - requestID: route.requestID, - sessionID: route.sessionID, - }); + setPending( + route.kind === "session-done" + ? { + bindingID: pairing.bindingID, + connectionId: pairing.connectionId, + eventID: route.eventID, + expiresAtMs: route.expiresAtMs, + kind: "session-done", + sessionID: route.sessionID, + } + : { + bindingID: pairing.bindingID, + connectionId: pairing.connectionId, + eventID: route.eventID, + expiresAtMs: route.expiresAtMs, + interaction: route.interaction, + kind: "interaction", + ...(route.location ? { location: route.location } : {}), + requestID: route.requestID, + sessionID: route.sessionID, + }, + ); if (connections.selectedProfileId !== pairing.connectionId) { await connections.select(pairing.connectionId); } @@ -155,7 +177,22 @@ export function NotificationRoutingProvider({ children }: { children: ReactNode processingEvents.current.delete(`${route.bindingID}\u0000${route.eventID}`); if (active) setPending(undefined); }; - if (route.sessionID === "global") { + if (route.kind === "session-done") { + const session = await getOpenCodeSession(restClient, route.sessionID); + if (!active) return; + queryClient.setQueryData( + openCodeQueryKeys.session(route.connectionId, session.location, session.id), + session, + ); + await waitForNavigation(); + if (active) { + rootNavigationRef.navigate("Session", { + connectionId: route.connectionId, + location: session.location, + sessionID: session.id, + }); + } + } else if (route.sessionID === "global") { if (!route.location) throw new Error("NOTIFICATION_LOCATION_REQUIRED"); const forms = await listOpenCodeFormRequests(restClient, route.location); queryClient.setQueryData( diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index 004144a..640e62c 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -1,8 +1,11 @@ +import type { NotificationDeliveryState } from "@opencode2-mobile/notification-protocol"; import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { useSQLiteContext } from "expo-sqlite"; import { StatusBar } from "expo-status-bar"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { ActivityIndicator, + AppState, Pressable, ScrollView, Share, @@ -16,6 +19,12 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { useConnections } from "../connections/connections-context"; import type { RootStackParamList } from "../navigation/root-navigation"; +import { sendNotificationDeviceCommand } from "../notifications/notification-client"; +import { + getNotificationPairingByConnectionID, + type NotificationPairing, + readNotificationPairingSecret, +} from "../notifications/notification-pairing-repository"; import { useAppLock } from "../security/app-lock-context"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import type { ConnectionTransportStatus } from "../state/connection-transport-coordinator"; @@ -227,12 +236,18 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending"> } export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { + const db = useSQLiteContext(); const appLock = useAppLock(); const runtime = useConnectionRuntime(); const connections = useConnections(); const selected = connections.profiles.find( (profile) => profile.id === connections.selectedProfileId, ); + const [notificationPairing, setNotificationPairing] = useState(); + const [notificationState, setNotificationState] = useState(); + const [notificationLoading, setNotificationLoading] = useState(true); + const [notificationBusy, setNotificationBusy] = useState(false); + const [notificationError, setNotificationError] = useState(false); const navigate = (section: Section) => section === "Workspace" ? navigation.popTo("Workspace") : navigation.navigate(section); @@ -240,6 +255,68 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { await Share.share({ message: runtime.getDiagnosticsText() }); } + useEffect(() => { + let active = true; + async function refresh() { + setNotificationLoading(true); + setNotificationError(false); + try { + const pairing = connections.selectedProfileId + ? await getNotificationPairingByConnectionID(db, connections.selectedProfileId) + : undefined; + if (!active) return; + setNotificationPairing(pairing); + if (!pairing) { + setNotificationState(undefined); + return; + } + const secret = await readNotificationPairingSecret(pairing); + const state = await sendNotificationDeviceCommand({ + bindingID: pairing.bindingID, + brokerOrigin: pairing.brokerOrigin, + deviceKey: secret.deviceKey, + operation: "status", + }); + if (active) setNotificationState(state); + } catch { + if (active) { + setNotificationState(undefined); + setNotificationError(true); + } + } finally { + if (active) setNotificationLoading(false); + } + } + void refresh(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void refresh(); + }); + return () => { + active = false; + subscription.remove(); + }; + }, [connections.selectedProfileId, db]); + + async function setNotificationsEnabled(enabled: boolean) { + if (!notificationPairing) return; + setNotificationBusy(true); + setNotificationError(false); + try { + const secret = await readNotificationPairingSecret(notificationPairing); + const state = await sendNotificationDeviceCommand({ + bindingID: notificationPairing.bindingID, + brokerOrigin: notificationPairing.brokerOrigin, + deviceKey: secret.deviceKey, + operation: enabled ? "enable" : "pause", + }); + setNotificationState(state); + } catch { + setNotificationError(true); + } finally { + setNotificationBusy(false); + } + } + return ( @@ -270,6 +347,43 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { ) : null} + + + Mobile notifications + + {notificationLoading + ? "Reading the shared broker setting." + : !notificationPairing + ? "Pair this connection for notifications before enabling delivery." + : notificationState?.enabled + ? "Enabled for every phone paired with this notification broker." + : notificationState + ? "Paused. New requests will not create mobile notifications." + : "The shared broker setting is unavailable."} + + + {notificationLoading ? ( + + ) : ( + void setNotificationsEnabled(enabled)} + thumbColor={notificationState?.enabled ? palette.signal : palette.dim} + trackColor={{ false: palette.border, true: palette.signalDark }} + value={notificationState?.enabled ?? false} + /> + )} + + {notificationError ? ( + + The notification broker setting could not be read or updated. + + ) : null} + SESSION INBOX Followed projects diff --git a/apps/mobile/src/security/app-lock-context.test.tsx b/apps/mobile/src/security/app-lock-context.test.tsx index 9caafce..c9f9f8d 100644 --- a/apps/mobile/src/security/app-lock-context.test.tsx +++ b/apps/mobile/src/security/app-lock-context.test.tsx @@ -128,7 +128,7 @@ test("does not unlock after authentication finishes in the background", async () expect(screen.getByText("OpenCode2 Mobile is locked.")).toBeOnTheScreen(); }); -test("unlocks after a successful native prompt returns through inactive", async () => { +test("keeps unlock busy until a successful native prompt returns through inactive", async () => { let resolveAuthentication: ((result: "AUTHENTICATED") => void) | undefined; mockAuthenticate.mockImplementation( () => @@ -152,6 +152,7 @@ test("unlocks after a successful native prompt returns through inactive", async await Promise.resolve(); }); expect(screen.queryByText("Private content")).not.toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "AUTHENTICATING" })).toBeDisabled(); Object.defineProperty(AppState, "currentState", { configurable: true, value: "active" }); act(() => appStateListener?.("active")); diff --git a/apps/mobile/src/security/app-lock-context.tsx b/apps/mobile/src/security/app-lock-context.tsx index ee7cfa4..455fd0c 100644 --- a/apps/mobile/src/security/app-lock-context.tsx +++ b/apps/mobile/src/security/app-lock-context.tsx @@ -68,6 +68,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) { const subscription = AppState.addEventListener("change", (nextState) => { if (nextState === "background") { if (unlockInProgressRef.current) unlockBackgroundedRef.current = true; + if (pendingUnlockRef.current) setBusy(false); pendingUnlockRef.current = false; } if (nextState !== "active") { @@ -75,6 +76,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) { } else if (pendingUnlockRef.current && !unlockBackgroundedRef.current) { pendingUnlockRef.current = false; setLocked(false); + setBusy(false); } }); return () => subscription.remove(); @@ -139,7 +141,10 @@ export function AppLockProvider({ children }: { children: ReactNode }) { return; } if (AppState.currentState === "active") setLocked(false); - else pendingUnlockRef.current = true; + else { + pendingUnlockRef.current = true; + setBusy(true); + } } function retryPreferenceLoad() { diff --git a/apps/mobile/src/state/connection-runtime-context.tsx b/apps/mobile/src/state/connection-runtime-context.tsx index c7e07da..4b5f98d 100644 --- a/apps/mobile/src/state/connection-runtime-context.tsx +++ b/apps/mobile/src/state/connection-runtime-context.tsx @@ -43,6 +43,7 @@ import { } from "./transcript-performance"; type ConnectionRuntimeContextValue = { + attentionLocations: LocationRef[]; cacheMetadata?: ConnectionCacheMetadata; connectionId?: string; connectionUpdatedAtMs?: number; @@ -68,6 +69,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode }) const [cacheMetadata, setCacheMetadata] = useState(); const [serverVersion, setServerVersion] = useState(); const [eventLocations, setEventLocations] = useState([]); + const [attentionLocations, setAttentionLocations] = useState([]); const [reconciliationRevision, setReconciliationRevision] = useState(0); const [restClientState, setRestClientState] = useState<{ client: OpenCodeClient; @@ -80,6 +82,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode }) (profile) => profile.id === connections.selectedProfileId, ); const includeAttentionLocation = useCallback((location: LocationRef) => { + setAttentionLocations((current) => appendEventLocation(current, location)); setEventLocations((current) => appendEventLocation(current, location)); }, []); @@ -94,6 +97,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode }) setCacheMetadata(undefined); setServerVersion(undefined); setEventLocations([]); + setAttentionLocations([]); setReconciliationRevision(0); setRestClientState(undefined); return; @@ -117,6 +121,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode }) setCacheMetadata(undefined); setServerVersion(undefined); setEventLocations([]); + setAttentionLocations([]); setReconciliationRevision(0); setRestClientState(undefined); void readConnectionCacheMetadata(selected.id, selected.updatedAtMs) @@ -243,6 +248,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode }) return ( session.location), ...Object.values(activeAncestryQuery.data?.sessions ?? {}) diff --git a/apps/mobile/src/state/workspace-selection-context.test.tsx b/apps/mobile/src/state/workspace-selection-context.test.tsx index b962f4c..66087db 100644 --- a/apps/mobile/src/state/workspace-selection-context.test.tsx +++ b/apps/mobile/src/state/workspace-selection-context.test.tsx @@ -54,6 +54,7 @@ const defaultListProjectSessions: ListProjectSessionsCall = async (_client, proj const mockListProjectSessions = jest.fn(defaultListProjectSessions); const mockReplyPermission = jest.fn(); let mockEventLocations: LocationRef[] = []; +let mockAttentionLocations: LocationRef[] = []; let mockRevision = 1; let mockStatus: "connected" | "offline" = "connected"; let mockConnectionUpdatedAtMs = 1; @@ -109,6 +110,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ })); jest.mock("./connection-runtime-context", () => ({ useConnectionRuntime: () => ({ + attentionLocations: mockAttentionLocations, connectionId: "connection-1", connectionUpdatedAtMs: mockConnectionUpdatedAtMs, eventLocations: mockEventLocations, @@ -121,6 +123,7 @@ jest.mock("./connection-runtime-context", () => ({ beforeEach(() => { jest.useFakeTimers(); mockEventLocations = []; + mockAttentionLocations = []; mockRevision = 1; mockStatus = "connected"; mockConnectionUpdatedAtMs = 1; @@ -304,6 +307,50 @@ test("starts a fresh reconciliation revision and includes event locations", asyn queryClient.clear(); }); +test("keeps a notification-owned global form visible outside followed projects", async () => { + mockAttentionLocations = [{ directory: "/outside" }]; + mockListPermissions.mockImplementation(async (_client, location) => ({ + data: [], + location: mockResolvedLocation(location.directory, "project-outside"), + })); + mockGetOpenCodeLocation.mockImplementation(async (_client, location) => + mockResolvedLocation(location.directory, "project-outside"), + ); + mockListForms.mockImplementation(async (_client, location) => ({ + data: + location.directory === "/outside" + ? [ + { + fields: [{ key: "result", type: "string" as const }], + id: "frm_global", + sessionID: "global", + title: "Notification routing test", + }, + ] + : [], + location: mockResolvedLocation(location.directory, "project-outside"), + })); + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { networkMode: "always" }, + queries: { gcTime: Infinity, retry: false }, + }, + }); + const view = render( + + + + + , + ); + + await waitFor(() => expect(screen.getByText(/^complete:1:/)).toBeOnTheScreen()); + expect(mockListForms.mock.calls.some((call) => call[1].directory === "/outside")).toBe(true); + + view.unmount(); + queryClient.clear(); +}); + test("keeps blocked work visible through disconnect and foreground reconciliation", async () => { const queryClient = new QueryClient({ defaultOptions: { diff --git a/apps/notification-broker/src/cli.ts b/apps/notification-broker/src/cli.ts index 5ddd26f..c171b23 100644 --- a/apps/notification-broker/src/cli.ts +++ b/apps/notification-broker/src/cli.ts @@ -30,6 +30,9 @@ try { else if (command === "serve") await serve(); else if (command === "pair") await pair(arguments_); else if (command === "devices") devices(); + else if (command === "status") status(); + else if (command === "enable") setEnabled(true); + else if (command === "pause") setEnabled(false); else if (command === "revoke") revoke(arguments_); else if (command === "test") test(arguments_); else usage(1); @@ -144,6 +147,21 @@ function devices() { }); } +function status() { + withDatabase((database) => { + process.stdout.write( + `Mobile notifications: ${database.deliveryState().enabled ? "enabled" : "paused"}\n`, + ); + }); +} + +function setEnabled(enabled: boolean) { + withDatabase((database) => { + database.setDeliveryEnabled(enabled); + process.stdout.write(`Mobile notifications ${enabled ? "enabled" : "paused"}.\n`); + }); +} + function revoke(arguments_: string[]) { const bindingID = arguments_[0]; if (!bindingID) throw new Error("BINDING_ID_REQUIRED"); @@ -257,6 +275,9 @@ function usage(exitCode: number): never { opencode-mobile-notifications serve opencode-mobile-notifications pair --name NAME --opencode-origin URL [--auth none|basic|bearer] [--allow-http] opencode-mobile-notifications devices + opencode-mobile-notifications status + opencode-mobile-notifications enable + opencode-mobile-notifications pause opencode-mobile-notifications revoke BINDING_ID opencode-mobile-notifications test BINDING_ID `); diff --git a/apps/notification-broker/src/database.test.ts b/apps/notification-broker/src/database.test.ts index 4951301..d127465 100644 --- a/apps/notification-broker/src/database.test.ts +++ b/apps/notification-broker/src/database.test.ts @@ -79,6 +79,42 @@ describe("BrokerDatabase", () => { database.close(); }); + it("pauses delivery without replaying interactions after resume", () => { + const database = new BrokerDatabase(":memory:", randomBytes(32)); + pairDevice(database, "binding-1", 500); + expect(database.deliveryState()).toEqual({ enabled: true, updatedAtMs: 0, v: 1 }); + + expect(database.setDeliveryEnabled(false, 900)).toEqual({ + enabled: false, + updatedAtMs: 900, + v: 1, + }); + expect(database.nextQueued()).toHaveLength(0); + database.acceptPluginEvent({ + eventID: "evt_paused", + interaction: "permission", + observedAtMs: 1_000, + requestID: "per_paused", + sessionID: "ses_1", + state: "pending", + v: 1, + }); + database.setDeliveryEnabled(true, 1_100); + expect(database.nextQueued()).toHaveLength(0); + + database.acceptPluginEvent({ + eventID: "evt_resumed", + interaction: "permission", + observedAtMs: 1_200, + requestID: "per_resumed", + sessionID: "ses_1", + state: "pending", + v: 1, + }); + expect(database.nextQueued()).toHaveLength(1); + database.close(); + }); + it("cancels a queued interaction when OpenCode resolves it", () => { const database = new BrokerDatabase(":memory:", randomBytes(32)); pairDevice(database, "binding-1", 500); @@ -131,6 +167,57 @@ describe("BrokerDatabase", () => { database.close(); }); + it("queues and deduplicates successful session completion", () => { + const database = new BrokerDatabase(":memory:", randomBytes(32)); + pairDevice(database, "binding-1", 500); + const event = { + category: "session-done", + eventID: "evt_done", + kind: "session-done", + observedAtMs: 1_000, + sessionID: "ses_1", + v: 1, + }; + + database.acceptPluginEvent(event); + database.acceptPluginEvent(event); + + expect( + database.database + .prepare("SELECT notification_category, state FROM outbox WHERE event_id = 'evt_done'") + .get(), + ).toEqual({ notification_category: "session-done", state: "queued" }); + expect( + database.database + .prepare("SELECT state FROM plugin_events WHERE event_id = 'evt_done'") + .get(), + ).toEqual({ state: "emitted" }); + database.close(); + }); + + it("stores only the allowlisted visible category", () => { + const database = new BrokerDatabase(":memory:", randomBytes(32)); + pairDevice(database, "binding-1", 500); + database.acceptPluginEvent({ + category: "permission-shell", + eventID: "evt_shell", + interaction: "permission", + kind: "interaction", + observedAtMs: 1_000, + requestID: "per_1", + sessionID: "ses_1", + state: "pending", + v: 1, + }); + + expect( + database.database + .prepare("SELECT notification_category FROM outbox WHERE event_id = 'evt_shell'") + .get(), + ).toEqual({ notification_category: "permission-shell" }); + database.close(); + }); + it("prunes expired unused challenges without removing the retry window", () => { const database = new BrokerDatabase(":memory:", randomBytes(32)); const consumed = createPairingCode(database, 1_000); diff --git a/apps/notification-broker/src/database.ts b/apps/notification-broker/src/database.ts index 14ebc8c..730a6c2 100644 --- a/apps/notification-broker/src/database.ts +++ b/apps/notification-broker/src/database.ts @@ -6,7 +6,9 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite"; import { decodeNotificationBytes, encodeNotificationBytes, + type NotificationCategory, type NotificationConnectionBootstrap, + type NotificationDeliveryState, type NotificationPairingCode, type NotificationPairingResponse, type NotificationPluginEvent, @@ -90,7 +92,7 @@ export class BrokerDatabase { ) STRICT; CREATE TABLE IF NOT EXISTS plugin_events ( event_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'resolved')), + state TEXT NOT NULL CHECK (state IN ('emitted', 'pending', 'resolved')), observed_at_ms INTEGER NOT NULL, PRIMARY KEY (event_id, state) ) STRICT; @@ -99,6 +101,7 @@ export class BrokerDatabase { binding_id TEXT NOT NULL REFERENCES devices(binding_id) ON DELETE CASCADE, event_id TEXT NOT NULL, interaction_key TEXT, + notification_category TEXT NOT NULL DEFAULT 'permission-other', push_data_json TEXT NOT NULL, collapse_id TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('queued', 'ticketed', 'delivered', 'failed')), @@ -117,6 +120,13 @@ export class BrokerDatabase { used_at_ms INTEGER NOT NULL, PRIMARY KEY (binding_id, nonce_id) ) STRICT; + CREATE TABLE IF NOT EXISTS notification_settings ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + updated_at_ms INTEGER NOT NULL + ) STRICT; + INSERT OR IGNORE INTO notification_settings(singleton, enabled, updated_at_ms) + VALUES (1, 1, 0); `); this.migratePreReleaseSchema(); if (path !== ":memory:") { @@ -135,6 +145,7 @@ export class BrokerDatabase { ["pairing_challenges", "paired_at_ms", "INTEGER"], ["pairing_challenges", "binding_id", "TEXT"], ["outbox", "interaction_key", "TEXT"], + ["outbox", "notification_category", "TEXT NOT NULL DEFAULT 'permission-other'"], ] as const; const missing = additions.filter(([table, column]) => { const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ @@ -142,12 +153,36 @@ export class BrokerDatabase { }>; return !columns.some((candidate) => candidate.name === column); }); - if (missing.length === 0) return; + const pluginEventsSql = this.database + .prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'plugin_events'") + .get() as { sql: string }; + const rebuildPluginEvents = !pluginEventsSql.sql.includes("'emitted'"); + if (missing.length === 0 && !rebuildPluginEvents) return; this.database.exec("BEGIN IMMEDIATE"); try { for (const [table, column, type] of missing) { this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); } + if ( + missing.some(([table, column]) => table === "outbox" && column === "notification_category") + ) { + this.database + .prepare("UPDATE outbox SET notification_category = 'test' WHERE event_id LIKE 'test:%'") + .run(); + } + if (rebuildPluginEvents) { + this.database.exec(` + ALTER TABLE plugin_events RENAME TO plugin_events_before_completion; + CREATE TABLE plugin_events ( + event_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('emitted', 'pending', 'resolved')), + observed_at_ms INTEGER NOT NULL, + PRIMARY KEY (event_id, state) + ) STRICT; + INSERT INTO plugin_events SELECT * FROM plugin_events_before_completion; + DROP TABLE plugin_events_before_completion; + `); + } this.database.exec("COMMIT"); } catch (error) { this.database.exec("ROLLBACK"); @@ -319,13 +354,19 @@ export class BrokerDatabase { acceptPluginEvent(value: unknown) { const event = parseNotificationPluginEvent(value); + const eventState = event.kind === "session-done" ? "emitted" : event.state; this.transaction(() => { const inserted = this.database .prepare( "INSERT OR IGNORE INTO plugin_events(event_id, state, observed_at_ms) VALUES (?, ?, ?)", ) - .run(event.eventID, event.state, event.observedAtMs); - if (inserted.changes === 0 || event.state === "resolved") return; + .run(event.eventID, eventState, event.observedAtMs); + if ( + inserted.changes === 0 || + (event.kind === "interaction" && event.state === "resolved") || + !this.deliveryState().enabled + ) + return; const devices = this.database .prepare( `SELECT * FROM devices @@ -333,9 +374,12 @@ export class BrokerDatabase { ORDER BY created_at_ms ASC LIMIT 32`, ) .all(event.observedAtMs) as unknown as DeviceRow[]; - for (const device of devices) this.enqueueInteraction(device, event); + for (const device of devices) { + if (event.kind === "session-done") this.enqueueSessionDone(device, event); + else this.enqueueInteraction(device, event); + } }); - if (event.state === "resolved") { + if (event.kind === "interaction" && event.state === "resolved") { this.database .prepare( `UPDATE outbox SET state = 'failed', push_data_json = '{}', last_error_code = 'RESOLVED' @@ -397,6 +441,7 @@ export class BrokerDatabase { enqueueTest(bindingID: string, now = Date.now()) { const device = this.getEnabledDevice(bindingID); + if (!this.deliveryState().enabled) return; const route: NotificationRoutingEnvelope = { bindingID, expiresAtMs: now + 24 * 60 * 60_000, @@ -404,7 +449,7 @@ export class BrokerDatabase { kind: "test", v: 1, }; - this.enqueueRoute(device, `test:${randomUUID()}`, route, now); + this.enqueueRoute(device, `test:${randomUUID()}`, route, "test", now); } listDevices() { @@ -416,17 +461,57 @@ export class BrokerDatabase { .all(); } + deliveryState(): NotificationDeliveryState { + const row = this.database + .prepare("SELECT enabled, updated_at_ms FROM notification_settings WHERE singleton = 1") + .get() as { enabled: number; updated_at_ms: number }; + return { enabled: row.enabled === 1, updatedAtMs: row.updated_at_ms, v: 1 }; + } + + setDeliveryEnabled(enabled: boolean, now = Date.now()) { + this.transaction(() => { + this.database + .prepare( + "UPDATE notification_settings SET enabled = ?, updated_at_ms = ? WHERE singleton = 1", + ) + .run(enabled ? 1 : 0, now); + if (!enabled) { + this.database + .prepare( + `UPDATE outbox SET state = 'failed', push_data_json = '{}', last_error_code = 'PAUSED' + WHERE state = 'queued'`, + ) + .run(); + } + }); + return this.deliveryState(); + } + nextQueued(limit = 25, now = Date.now()) { return this.database .prepare( `SELECT o.*, d.expo_token_nonce, d.expo_token_ciphertext - FROM outbox o JOIN devices d ON d.binding_id = o.binding_id + FROM outbox o JOIN devices d ON d.binding_id = o.binding_id + JOIN notification_settings s ON s.singleton = 1 AND s.enabled = 1 WHERE o.state = 'queued' AND o.next_attempt_at_ms <= ? AND d.disabled_at_ms IS NULL ORDER BY o.created_at_ms ASC LIMIT ?`, ) .all(now, limit) as Array>; } + canSendQueued(id: string) { + return Boolean( + this.database + .prepare( + `SELECT 1 FROM outbox o + JOIN devices d ON d.binding_id = o.binding_id + JOIN notification_settings s ON s.singleton = 1 AND s.enabled = 1 + WHERE o.id = ? AND o.state = 'queued' AND d.disabled_at_ms IS NULL`, + ) + .get(id), + ); + } + nextReceipts(limit = 100, now = Date.now()) { return this.database .prepare( @@ -451,7 +536,7 @@ export class BrokerDatabase { this.database .prepare( `UPDATE outbox SET state = 'ticketed', ticket_id = ?, receipt_due_at_ms = ?, - attempts = attempts + 1 WHERE id = ?`, + attempts = attempts + 1 WHERE id = ? AND state = 'queued'`, ) .run(ticketID, now + 15 * 60_000, id); } @@ -467,9 +552,14 @@ export class BrokerDatabase { } markRetry(id: string, errorCode: string, now = Date.now()) { - const row = this.database.prepare("SELECT attempts FROM outbox WHERE id = ?").get(id) as - | { attempts: number } - | undefined; + if (!this.deliveryState().enabled) { + this.markFailed(id, "PAUSED"); + return; + } + const row = this.database + .prepare("SELECT attempts FROM outbox WHERE id = ? AND state = 'queued'") + .get(id) as { attempts: number } | undefined; + if (!row) return; const attempts = (row?.attempts ?? 0) + 1; const delay = Math.min(15 * 60_000, 2 ** Math.min(attempts, 8) * 1_000); this.database @@ -528,7 +618,10 @@ export class BrokerDatabase { return device; } - private enqueueInteraction(device: DeviceRow, event: NotificationPluginEvent) { + private enqueueInteraction( + device: DeviceRow, + event: Extract, + ) { const route: NotificationRoutingEnvelope = { bindingID: device.binding_id, eventID: event.eventID, @@ -541,13 +634,37 @@ export class BrokerDatabase { sessionID: event.sessionID, v: 1, }; - this.enqueueRoute(device, event.eventID, route, event.observedAtMs, interactionKey(event)); + this.enqueueRoute( + device, + event.eventID, + route, + event.category, + event.observedAtMs, + interactionKey(event), + ); + } + + private enqueueSessionDone( + device: DeviceRow, + event: Extract, + ) { + const route: NotificationRoutingEnvelope = { + bindingID: device.binding_id, + eventID: event.eventID, + expiresAtMs: event.observedAtMs + 7 * 24 * 60 * 60_000, + issuedAtMs: event.observedAtMs, + kind: "session-done", + sessionID: event.sessionID, + v: 1, + }; + this.enqueueRoute(device, event.eventID, route, event.category, event.observedAtMs); } private enqueueRoute( device: DeviceRow, eventID: string, route: NotificationRoutingEnvelope, + category: NotificationCategory, now: number, interactionKeyValue?: string, ) { @@ -567,15 +684,17 @@ export class BrokerDatabase { this.database .prepare( `INSERT OR IGNORE INTO outbox ( - id, binding_id, event_id, interaction_key, push_data_json, collapse_id, state, + id, binding_id, event_id, interaction_key, notification_category, push_data_json, + collapse_id, state, next_attempt_at_ms, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)`, ) .run( randomUUID(), device.binding_id, eventID, interactionKeyValue ?? null, + category, JSON.stringify(pushData), collapseID, now, @@ -596,7 +715,7 @@ export class BrokerDatabase { } } -function interactionKey(event: NotificationPluginEvent) { +function interactionKey(event: Extract) { return `${event.interaction}\u0000${event.sessionID}\u0000${event.requestID}`; } diff --git a/apps/notification-broker/src/expo-push.test.ts b/apps/notification-broker/src/expo-push.test.ts new file mode 100644 index 0000000..836c80c --- /dev/null +++ b/apps/notification-broker/src/expo-push.test.ts @@ -0,0 +1,88 @@ +import { randomBytes } from "node:crypto"; +import type { NotificationCategory } from "@opencode2-mobile/notification-protocol"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BrokerDatabase } from "./database.js"; +import { ExpoPushWorker, notificationBody } from "./expo-push.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("notificationBody", () => { + it.each([ + ["form", "OpenCode has a question for you"], + ["permission-question", "OpenCode has a question for you"], + ["permission-edit", "Permission to edit files"], + ["permission-execute", "Permission to use Code Mode"], + ["permission-external-directory", "Permission to access external files"], + ["permission-glob", "Permission to search file paths"], + ["permission-grep", "Permission to search file contents"], + ["permission-other", "Permission requested"], + ["permission-read", "Permission to read a file"], + ["permission-shell", "Permission to run a command"], + ["permission-skill", "Permission to load a skill"], + ["permission-subagent", "Permission to start a subagent"], + ["permission-webfetch", "Permission to fetch a URL"], + ["permission-websearch", "Permission to search the web"], + ["session-done", "Session done"], + ["test", "OpenCode needs your attention."], + ] satisfies Array<[NotificationCategory, string]>)("maps %s to safe copy", (category, body) => { + expect(notificationBody(category)).toBe(body); + }); +}); + +it("does not send later rows from a batch after delivery is paused", async () => { + const database = new BrokerDatabase(":memory:", randomBytes(32)); + const code = database.createPairing( + { + allowDevelopmentHttp: true, + authMode: "none", + brokerOrigin: "http://broker.test:37100", + name: "Test server", + openCodeOrigin: "http://server.test:4096", + }, + { + allowDevelopmentHttp: true, + auth: { mode: "none" }, + baseUrl: "http://server.test:4096", + name: "Test server", + v: 1, + }, + 500, + ); + database.completePairing( + code.challengeID, + { + bindingID: "binding-1", + deviceKey: Buffer.from(randomBytes(32)).toString("base64url"), + deviceName: "Test phone", + expoPushToken: "ExponentPushToken[test-token]", + platform: "ios", + v: 1, + }, + "broker-1", + 501, + ); + database.enqueueTest("binding-1", 502); + const fetch = vi.fn(async () => { + database.setDeliveryEnabled(false, 600); + return Response.json({ data: { id: "ticket-1", status: "ok" } }); + }); + globalThis.fetch = fetch; + + await new ExpoPushWorker(database, undefined, "expo").tick(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect( + database.database + .prepare("SELECT state, last_error_code FROM outbox ORDER BY created_at_ms") + .all(), + ).toEqual([ + { last_error_code: "PAUSED", state: "failed" }, + { last_error_code: "PAUSED", state: "failed" }, + ]); + database.close(); +}); diff --git a/apps/notification-broker/src/expo-push.ts b/apps/notification-broker/src/expo-push.ts index a8db3c4..d6191c7 100644 --- a/apps/notification-broker/src/expo-push.ts +++ b/apps/notification-broker/src/expo-push.ts @@ -1,4 +1,5 @@ import type { SQLInputValue } from "node:sqlite"; +import type { NotificationCategory } from "@opencode2-mobile/notification-protocol"; import type { BrokerDatabase } from "./database.js"; @@ -48,6 +49,7 @@ export class ExpoPushWorker { for (const row of this.database.nextQueued()) { const id = asString(row.id); const bindingID = asString(row.binding_id); + if (!this.database.canSendQueued(id)) continue; if (this.mode === "fake") { this.database.markDelivered(id); continue; @@ -55,7 +57,7 @@ export class ExpoPushWorker { try { const response = await fetch(expoSendUrl, { body: JSON.stringify({ - body: "OpenCode needs your attention.", + body: notificationBody(asNotificationCategory(row.notification_category)), channelId: "opencode-attention", collapseId: asString(row.collapse_id), data: JSON.parse(asString(row.push_data_json)), @@ -145,6 +147,42 @@ export class ExpoPushWorker { } } +export function notificationBody(category: NotificationCategory) { + switch (category) { + case "form": + case "permission-question": + return "OpenCode has a question for you"; + case "permission-edit": + return "Permission to edit files"; + case "permission-execute": + return "Permission to use Code Mode"; + case "permission-external-directory": + return "Permission to access external files"; + case "permission-glob": + return "Permission to search file paths"; + case "permission-grep": + return "Permission to search file contents"; + case "permission-read": + return "Permission to read a file"; + case "permission-shell": + return "Permission to run a command"; + case "permission-skill": + return "Permission to load a skill"; + case "permission-subagent": + return "Permission to start a subagent"; + case "permission-webfetch": + return "Permission to fetch a URL"; + case "permission-websearch": + return "Permission to search the web"; + case "session-done": + return "Session done"; + case "permission-other": + return "Permission requested"; + case "test": + return "OpenCode needs your attention."; + } +} + type ExpoResult = { error?: string; id?: string; status: "error" | "ok" }; function parseTicket( @@ -190,6 +228,31 @@ function asNumber(value: SQLInputValue | undefined) { return value; } +function asNotificationCategory(value: SQLInputValue | undefined): NotificationCategory { + if (typeof value !== "string") throw new Error("INVALID_BROKER_DATA"); + switch (value) { + case "form": + case "permission-edit": + case "permission-execute": + case "permission-external-directory": + case "permission-glob": + case "permission-grep": + case "permission-other": + case "permission-question": + case "permission-read": + case "permission-shell": + case "permission-skill": + case "permission-subagent": + case "permission-webfetch": + case "permission-websearch": + case "session-done": + case "test": + return value; + default: + throw new Error("INVALID_BROKER_DATA"); + } +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/notification-broker/src/server.ts b/apps/notification-broker/src/server.ts index 66b7826..b07b70d 100644 --- a/apps/notification-broker/src/server.ts +++ b/apps/notification-broker/src/server.ts @@ -117,6 +117,8 @@ async function handlePublic( if (request.method === "POST" && request.url?.startsWith("/v1/device/")) { const operation = request.url.slice("/v1/device/".length); if ( + operation !== "enable" && + operation !== "pause" && operation !== "status" && operation !== "token" && operation !== "test" && @@ -142,13 +144,17 @@ async function handlePublic( if (command.operation !== operation) throw new HttpError(400, "OPERATION_MISMATCH"); if (operation === "token" && command.expoPushToken) { database.updateDeviceToken(input.bindingID, command.expoPushToken); + } else if (operation === "enable") { + database.setDeliveryEnabled(true); + } else if (operation === "pause") { + database.setDeliveryEnabled(false); } else if (operation === "test") { database.enqueueTest(input.bindingID); void worker.tick(); } else if (operation === "revoke") { database.revokeDevice(input.bindingID); } - writeJson(response, 200, { ok: true, operation }); + writeJson(response, 200, database.deliveryState()); return; } throw new HttpError(404, "NOT_FOUND"); @@ -165,9 +171,6 @@ async function handlePlugin( ) { setHeaders(response); try { - if (request.method !== "POST" || request.url !== "/v1/plugin/events") { - throw new HttpError(404, "NOT_FOUND"); - } const authorization = request.headers.authorization; if ( !authorization?.startsWith("Bearer ") || @@ -175,6 +178,20 @@ async function handlePlugin( ) { throw new HttpError(401, "UNAUTHORIZED"); } + if (request.method === "GET" && request.url === "/v1/plugin/status") { + writeJson(response, 200, database.deliveryState()); + return; + } + if ( + request.method === "POST" && + (request.url === "/v1/plugin/enable" || request.url === "/v1/plugin/pause") + ) { + writeJson(response, 200, database.setDeliveryEnabled(request.url.endsWith("/enable"))); + return; + } + if (request.method !== "POST" || request.url !== "/v1/plugin/events") { + throw new HttpError(404, "NOT_FOUND"); + } const body = await readJson(request, 64 * 1_024); if (!isRecord(body) || !Array.isArray(body.events) || body.events.length > 100) { throw new HttpError(400, "INVALID_PLUGIN_BATCH"); diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 567d428..a9416a3 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -499,3 +499,47 @@ prompt, path, or server content. A full host reboot, forced provider-token rotation, Android revocation/re-pairing, and device-log review remain open. The device-log review could not run because Android platform tools were unavailable on the test host. + +## 2026-08-26: physical iPhone notification controls probe + +### Stack + +- Mobile runtime: signed EAS preview build with an iOS EAS Update using Hermes +- Expo SDK: 54.0.37 +- React Native: 0.81.5 +- OpenCode server: beta 18286 +- OpenCode plugin and mobile client contract: beta 18050 +- Delivery: self-hosted Linux broker, Expo Push Service, and APNs + +### Results + +| Probe | Result | +| --- | --- | +| Deliver banner, sound, and notification-list entry while app is backgrounded | Pass | +| Wake the locked screen, present the notification, and play sound | Pass | +| Suppress banner, sound, badge, and notification-list entry while app is foregrounded | Pass | +| Pause from mobile and observe the broker state | Pass | +| Suppress delivery while paused and avoid replay after enabling | Pass | +| Enable delivery and receive a new post-resume notification | Pass | +| Load TUI status, pause, and enable commands from the command palette and slash completion | Pass | +| Reflect TUI changes after reopening mobile Settings | Pass | +| Preserve paused state and suppression across a broker restart | Pass | +| Route and settle a real permission in mobile and the TUI | Pass | +| Cold-start from a permission notification and route to the owning session | Pass | +| Route a global form at an exact non-followed location and submit it | Pass after fix | + +The first global-form probe opened Pending but showed zero requests. The +notification location had been combined with ordinary event locations and then +filtered out because its project was not followed. Notification-owned locations +now remain explicit, while ordinary event locations retain the followed-project +filter. A focused provider regression test failed before the change and passed +after it. The corrected preview update then passed on the same physical iPhone. + +The tested OpenCode beta did not resolve the local package directory as a server +plugin, and registering a keymap layer directly during TUI plugin setup failed +before the keymap provider mounted. The tested configuration loads the compiled +server and TUI files separately. The TUI commands register from a provider-backed +slot component. + +The probe recorded no address, credential, token, pairing code, identifier, +prompt, path, form response, or server content. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 6467f66..b3bcf74 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -1,7 +1,8 @@ # Self-hosted push notifications The self-hosted notification flow can pair one phone with an OpenCode V2 -connection and send generic permission or form alerts through Expo Push Service. +connection and send sanitized permission, form, and successful session-completion +alerts through Expo Push Service. ```text OpenCode V2 plugin @@ -19,10 +20,18 @@ and stores it in an encrypted, short-lived bootstrap challenge so the phone can save it to SecureStore. The built-in OpenCode `/pair` flow instead sends the credential to the broker once for same-host validation before creating that challenge. Consumed challenges remain encrypted for bounded idempotent pairing -retries and are then pruned. Expo, APNs, and FCM see generic notification text -plus an encrypted routing envelope. The phone unlocks first, decrypts the route, -selects the paired connection, and fetches the session from OpenCode before -navigating. +retries and are then pruned. Expo, APNs, and FCM see text selected from a finite +category allowlist plus an encrypted routing envelope. They never receive raw +permission actions, resources, paths, prompts, form titles, session titles, +errors, or identifiers in the visible text. The phone unlocks first, decrypts +the route, selects the paired connection, and fetches the session from OpenCode +before navigating. + +Permission categories use short phrases such as `Permission to run a command`, +`Permission to edit files`, or `Permission to search the web`. Unknown plugin +actions fall back to `Permission requested`. Forms use `OpenCode has a question +for you`. A `session.execution.succeeded` event uses `Session done`; idle, failed, +and interrupted events do not produce that notification. ## Requirements @@ -76,7 +85,7 @@ OpenCode. { "plugins": [ { - "package": "file:///absolute/path/opencode2-mobile/packages/opencode-notification-plugin", + "package": "/absolute/path/opencode2-mobile/packages/opencode-notification-plugin/dist/index.js", "options": { "brokerOrigin": "http://127.0.0.1:37101", "tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token" @@ -87,15 +96,38 @@ OpenCode. ``` The package is pinned to `@opencode-ai/plugin@0.0.0-beta-18050`. It subscribes to -`permission.asked`, `permission.replied`, `form.created`, `form.replied`, and -`form.cancelled`. It stores a sanitized retry queue in plugin storage before it -posts to the broker. +`permission.asked`, `permission.replied`, `form.created`, `form.replied`, +`form.cancelled`, and `session.execution.succeeded`. It converts permission +actions to a finite category before storing a sanitized retry queue in plugin +storage and posting it to the broker. The V2 plugin context has no permission or form snapshot list operation. The plugin cannot send retroactive notifications for requests created before plugin installation, and a server crash before an event reaches plugin storage can lose that notification. The app still reconciles authoritative state after every tap. +For controls in a locally installed OpenCode TUI, add the compiled TUI entry to +`~/.config/opencode/cli.json` with the same options: + +```json +{ + "plugins": [ + { + "package": "/absolute/path/opencode2-mobile/packages/opencode-notification-plugin/dist/tui.js", + "options": { + "brokerOrigin": "http://127.0.0.1:37101", + "tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token" + } + } + ] +} +``` + +The local source package directory is not a valid server plugin entry in the +tested OpenCode beta. Configure the two compiled files explicitly, rebuild after +changes, restart the OpenCode service for server-plugin changes, and reopen the +TUI for TUI-plugin changes. + ## Start and pair Start the broker in one terminal: @@ -193,10 +225,25 @@ Useful broker commands: ```sh fnm exec --using=26.7.0 pnpm notifications:broker -- devices +fnm exec --using=26.7.0 pnpm notifications:broker -- status +fnm exec --using=26.7.0 pnpm notifications:broker -- pause +fnm exec --using=26.7.0 pnpm notifications:broker -- enable fnm exec --using=26.7.0 pnpm notifications:broker -- test DEVICE_ID_PREFIX fnm exec --using=26.7.0 pnpm notifications:broker -- revoke DEVICE_ID_PREFIX ``` +The broker owns one persisted delivery switch shared by OpenCode2 Mobile and the +OpenCode TUI plugin. The mobile Settings screen reads and changes it for the +selected paired connection. In the TUI, use `/notifications-status`, +`/notifications-pause`, or `/notifications-enable`, or run the matching command +from the command palette. Pausing discards pushes that have not yet been sent and +does not replay requests after notifications are enabled again. A push already +submitted to Expo, APNs, or FCM cannot be recalled. + +OpenCode2 Mobile does not present a banner, notification-list entry, badge, or +sound when a push arrives while the app is in the foreground. Background and +locked-device presentation remains controlled by the operating system. + Set `OPENCODE_MOBILE_PUSH_MODE=fake` when starting the broker to exercise pairing, event ingestion, encryption, and the durable outbox without calling Expo. diff --git a/docs/PUSH_AGENT_RUNBOOK.md b/docs/PUSH_AGENT_RUNBOOK.md new file mode 100644 index 0000000..df8fcd9 --- /dev/null +++ b/docs/PUSH_AGENT_RUNBOOK.md @@ -0,0 +1,440 @@ +# Agent runbook: enable push on an OpenCode server + +This runbook is for an internal user who already has the distributed OpenCode2 +Mobile app installed on a physical phone. An agent running on the user's Linux +OpenCode server should follow it to install, configure, verify, or repair the +server-side notification components. + +Invoke the agent with: + +```text +Follow docs/PUSH_AGENT_RUNBOOK.md to enable push notifications for my OpenCode +server. Inspect the host before changing it, preserve existing state, and ask me +only for decisions or information you cannot discover safely. +``` + +Do the work on the server. Do not return only a list of commands for the user to +run. Stop for user input when this runbook identifies a user-owned decision. + +## Scope + +The server installation consists of: + +```text +OpenCode V2 plugin + -> authenticated HTTP on loopback +notification broker + -> Expo Push Service + -> the already-distributed mobile app +``` + +The server owner does not need an Expo account, EAS project, Firebase project, +APNs key, FCM key, `google-services.json`, or a new mobile build. The internal app +distributor owns those items. Do not ask the server owner to configure them. + +The broker does not proxy normal OpenCode traffic. The phone continues to call +the user's OpenCode server directly. The broker sends text selected from a finite +category allowlist and an encrypted route that the installed app resolves after +unlock. + +This repository currently does not publish the broker or plugin as standalone +packages. The server keeps a checkout and OpenCode loads the built plugin from +that checkout. + +## Completion states + +Finish in one of these states and name it in the final report: + +- `complete`: host checks, phone pairing, broker test delivery, and one new + permission, form, or successful session-completion notification pass. +- `awaiting-phone`: the host is ready, but the user still needs to pair or + confirm notifications on the installed app. +- `blocked`: a specific user-owned network decision, incompatible OpenCode + version, unsafe repair, or missing internal distribution requirement prevents + further work. + +Starting services is not completion. Verify every boundary available on the +host and identify the first boundary that remains unverified. + +## Safety rules + +- Never display or read the contents of `plugin.token`, `master.key`, the broker + database, OpenCode credentials, pairing codes, or push tokens. +- Never put credentials in command arguments, configuration, URLs, logs, or the + final report. The pairing CLI reads credentials from a TTY. +- Never rerun broker initialization when any broker config or state file exists. +- Never delete or replace `broker.sqlite3`, `master.key`, or `plugin.token` to + repair ports or configuration. Existing phone registrations depend on them. +- Preserve unrelated OpenCode configuration and existing plugin entries. +- Keep plugin ingress on loopback. Never expose or proxy the plugin port. +- Ask before changing a public hostname, TLS or VPN routing, firewall policy, + OpenCode authentication, or an existing service's port. +- Do not kill an unknown process that owns a desired port. +- Redact hostnames, addresses, usernames, paths, IDs, and server content from + output that leaves the machine. + +## Server requirements + +- Linux with a user-level systemd session. +- OpenCode V2 running under the same account as the broker. +- OpenCode `0.0.0-beta-18286`, the server version used for the latest physical + probe. The plugin dependency and mobile API contract remain pinned to beta + 18050. If the installed server differs, report it and ask whether the internal + deployment owner has approved that version before continuing. +- Node `26.7.0`, pnpm `11.21.0`, Git, and `fnm` or another way to run the pinned + Node release. +- A phone-reachable broker origin. Prefer HTTPS. Private Tailscale or LAN HTTP is + allowed only when the distributed app permits development HTTP and the user + explicitly approves it. +- OpenCode and the broker in the same host and network namespace. The plugin + intentionally refuses non-loopback broker ingress. + +Default ports: + +| Port | Scope | Purpose | +| --- | --- | --- | +| `4096` | Phone-reachable OpenCode listener | Mobile API and same-host pairing validation | +| `37100` | Phone-reachable broker listener | Health, pairing, and device commands | +| `37101` | `127.0.0.1` only | Authenticated plugin ingress | + +The public broker can use another port or a reverse proxy. The plugin listener +must remain HTTP on loopback even when the public broker uses HTTPS. + +## Agent workflow + +### 1. Inspect before changing + +Discover: + +- The account and home directory that run OpenCode. +- The OpenCode version, service status, listener, authentication mode, and port. +- Whether an `opencode2-mobile` checkout already exists. +- Existing broker files, systemd unit, listeners, and applicable OpenCode + configuration. +- Whether Tailscale, a reverse proxy, or an existing HTTPS hostname is available. + +Useful non-secret checks: + +```sh +id +opencode2 --version +opencode2 service status +ss -ltnp | grep -E ':(4096|37100|37101)\b' +systemctl --user status opencode-mobile-notifications.service +``` + +Broker files belong to the broker account: + +```text +${XDG_CONFIG_HOME:-$HOME/.config}/opencode-mobile-notifications/config.json +${XDG_STATE_HOME:-$HOME/.local/state}/opencode-mobile-notifications/broker.sqlite3 +${XDG_STATE_HOME:-$HOME/.local/state}/opencode-mobile-notifications/master.key +${XDG_STATE_HOME:-$HOME/.local/state}/opencode-mobile-notifications/plugin.token +``` + +Check for those paths by name and metadata only. Do not print secret file +contents. + +OpenCode can merge global and project configuration. Inspect the applicable +`~/.config/opencode/opencode.json(c)`, then project `opencode.json(c)` and +`.opencode/opencode.json(c)` files from the OpenCode working directory to its +project root. Do not add a duplicate plugin entry at a different precedence. + +Classify the host: + +- `fresh`: no broker config, state, unit, or plugin entry exists. +- `existing`: broker state exists and the installation may already work. +- `repair`: state or configuration exists but a verification boundary fails. + +A stopped service is not proof of a fresh installation. + +### 2. Resolve the phone-reachable broker origin + +Reuse an existing approved origin when possible. Ask the user to choose only if +the host does not already establish the intended route: + +- Public or private HTTPS through Caddy, nginx, Tailscale Serve, or equivalent. +- Explicitly approved Tailscale or private-LAN HTTP. + +The origin must be an origin root such as `https://push.example.test` or +`http://100.64.0.10:37100`. It cannot contain credentials, a path, query, or +fragment. + +For the built-in OpenCode `PAIR SERVER + NOTIFICATIONS` flow, the broker must use +the same scheme and hostname as the OpenCode URL, broker port `37100`, and Basic +authentication. Use the broker CLI pairing flow for bearer auth, no auth, a +different hostname or scheme, or a custom broker public port. + +Forward only the public broker listener. Never forward port `37101`. + +### 3. Install the server source + +If the correct checkout already exists, preserve and use it. Otherwise install +the internally approved revision. When the deployment owner has not supplied a +revision, use the repository containing this runbook and report the installed +commit: + +```sh +git clone https://github.com/omnicus/opencode2-mobile.git \ + "$HOME/.local/share/opencode2-mobile" +cd "$HOME/.local/share/opencode2-mobile" +git rev-parse HEAD +fnm exec --using=26.7.0 pnpm install --frozen-lockfile +fnm exec --using=26.7.0 pnpm notifications:build +``` + +Do not switch or update an existing checkout with uncommitted changes. Confirm +these build outputs exist: + +```text +packages/opencode-notification-plugin/dist/index.js +packages/opencode-notification-plugin/dist/tui.js +apps/notification-broker/dist/cli.js +``` + +### 4. Initialize a fresh broker + +Skip this section unless the host was classified `fresh`. Inspect desired ports +before initialization. + +For HTTPS terminated by a local reverse proxy: + +```sh +fnm exec --using=26.7.0 pnpm notifications:broker -- init \ + --public-origin https://push.example.test \ + --listen-host 127.0.0.1 \ + --public-port 37100 \ + --plugin-port 37101 \ + --opencode-port 4096 +``` + +For explicitly approved private HTTP, use the phone-reachable private address as +both `--public-origin` and `--listen-host`, and add `--allow-http`. Never use +Internet-facing HTTP. + +Initialization prints the token file path. Record the path, not its contents. + +### 5. Configure the OpenCode plugin + +Prefer global OpenCode configuration so notifications work for every project on +this server. Use project configuration only when the user requests that scope. +Merge one entry into the existing `plugins` array: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + { + "package": "/home/user/.local/share/opencode2-mobile/packages/opencode-notification-plugin/dist/index.js", + "options": { + "brokerOrigin": "http://127.0.0.1:37101", + "tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token" + } + } + ] +} +``` + +Use absolute paths and the actual plugin port from broker config. Put the token +file path in OpenCode configuration, never its value. + +Configure the TUI controls separately in `~/.config/opencode/cli.json`: + +```jsonc +{ + "plugins": [ + { + "package": "/home/user/.local/share/opencode2-mobile/packages/opencode-notification-plugin/dist/tui.js", + "options": { + "brokerOrigin": "http://127.0.0.1:37101", + "tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token" + } + } + ] +} +``` + +The tested OpenCode beta does not resolve the local source package directory as +a server plugin. Use the two compiled files shown above. + +OpenCode and the broker must share a network namespace. If OpenCode is in a +container, co-locate the broker there. Do not weaken the plugin's loopback check +or publish plugin ingress from the container. + +### 6. Install the broker service + +Place the tracked service template at +`~/.config/systemd/user/opencode-mobile-notifications.service`. Set `ExecStart` +to the absolute Node executable and absolute built CLI path: + +```sh +fnm exec --using=26.7.0 node -p 'process.execPath' +``` + +The resulting command has this shape: + +```text +/absolute/path/to/node /home/user/.local/share/opencode2-mobile/apps/notification-broker/dist/cli.js serve +``` + +Then start the broker: + +```sh +systemctl --user daemon-reload +systemctl --user enable --now opencode-mobile-notifications.service +systemctl --user status opencode-mobile-notifications.service +``` + +Ask an administrator to run `loginctl enable-linger USERNAME` only when the user +wants notifications while logged out. + +Start the broker before restarting OpenCode: + +```sh +opencode2 service restart +opencode2 service status +``` + +### 7. Verify the host + +Check each boundary in order. Diagnose and repair the first failure before +testing later boundaries. + +1. Confirm the broker public and loopback listeners and their process owner with + `ss -ltnp`. +2. Request `GET /healthz` from the local public listener. Expect `{"ok":true}`. +3. Request `/healthz` through the phone-reachable broker origin. This checks DNS, + TLS, proxy routing, and firewall access. +4. Run the broker `status` command. Confirm notifications are enabled. +5. In the OpenCode TUI, run `/notifications-status`. This proves the plugin + loaded, read its token file, and reached authenticated loopback ingress. + +Broker commands run from the checkout: + +```sh +fnm exec --using=26.7.0 pnpm notifications:broker -- status +fnm exec --using=26.7.0 pnpm notifications:broker -- devices +``` + +If all five checks pass but no phone is paired, report `awaiting-phone` and +continue with the user-assisted step. + +### 8. Pair the installed app + +Pairing requires the user and phone. Do not display a pairing code until the user +is ready to scan it. Pairing codes expire after two minutes and are bearer +secrets. Never put one in chat, logs, screenshots, or the final report. + +For the standard same-host Basic-auth setup, ask the user to open OpenCode's +built-in `/pair` dialog. In the installed app, the user opens Connections, +chooses `PAIR SERVER + NOTIFICATIONS`, and scans the QR. + +For other setups, prepare the broker pairing command for a trusted interactive +terminal. The agent may run it only when its shell has a TTY and the user can +enter the credential directly without revealing it to the agent. Otherwise ask +the user to run this one command locally on the server: + +```sh +fnm exec --using=26.7.0 pnpm notifications:broker -- pair \ + --name "Workstation" \ + --opencode-origin https://opencode.example.test \ + --auth bearer +``` + +Use `--auth none`, `--auth basic`, or `--auth bearer` to match OpenCode. Add +`--allow-http` only for an approved private HTTP OpenCode origin. The CLI prompts +for credentials without placing them in shell history. + +After the user finishes pairing: + +```sh +fnm exec --using=26.7.0 pnpm notifications:broker -- devices +fnm exec --using=26.7.0 pnpm notifications:broker -- test DEVICE_ID_PREFIX +``` + +Confirm one expected active device and ask the user to confirm the test push +while the app is backgrounded or the phone is locked. The app intentionally +suppresses visible notifications while it is in the foreground. + +Finally, create a new disposable permission or form request, or complete a new +session execution successfully, and ask the user to confirm the sanitized +notification text. Requests and executions created before plugin startup do not +generate retroactive notifications. + +## Repair and port diagnosis + +Do not initialize again. Gather evidence first: + +```sh +systemctl --user status opencode-mobile-notifications.service +journalctl --user -u opencode-mobile-notifications.service -n 100 --no-pager +ss -ltnp | grep -E ':(4096|37100|37101)\b' +opencode2 service status +``` + +Inspect broker `config.json` without reading adjacent secret files. Its values +must agree with actual listeners, reverse-proxy routing, plugin options, and the +pairing method: + +```json +{ + "listenHost": "127.0.0.1", + "openCodePairingPorts": [4096], + "pluginPort": 37101, + "publicOrigin": "https://push.example.test", + "publicPort": 37100 +} +``` + +To fix a port conflict or mismatch: + +1. Identify the current port owner. Ask before moving an existing service. +2. Stop `opencode-mobile-notifications.service`. +3. Back up only `config.json` to a mode-`600` file in the private config + directory. +4. Edit only `publicPort`, `pluginPort`, or `openCodePairingPorts` as required. + Preserve `brokerID`, `publicOrigin`, `allowDevelopmentHttp`, and unrelated + fields. +5. When `pluginPort` changes, update `options.brokerOrigin` in the effective + OpenCode config. It must remain `http://127.0.0.1:PORT`. +6. When the OpenCode port changes, update `openCodePairingPorts` for the built-in + pairing allowlist. +7. Restart the broker, inspect listeners and its journal, restart OpenCode, and + repeat host verification from the first boundary. + +A reverse proxy can expose `https://push.example.test` on port `443` while +forwarding to local port `37100`. In that case, changing the local public port +does not require changing `publicOrigin`. Ask before changing `publicOrigin`. +Existing phone pairings may need replacement when it changes. + +### Common failures + +| Symptom | Likely cause | Repair | +| --- | --- | --- | +| `BROKER_ALREADY_INITIALIZED` | State already exists | Stop. Inspect and repair the existing installation. | +| `BROKER_NOT_INITIALIZED` | Wrong account, `HOME`, XDG directories, or missing files | Compare service environment and file ownership. Restore missing state from backup rather than regenerating one file. | +| `INVALID_BROKER_CONFIG` | Invalid JSON, origin, host, or port | Stop the broker, back up config, and repair only invalid fields. HTTP also requires `allowDevelopmentHttp: true`. | +| `EADDRINUSE` | Another process owns a listener | Identify it with `ss`; ask which service should move; synchronize dependent config. | +| Service cannot find Node or modules | Relative or stale `ExecStart`, wrong Node, or unbuilt checkout | Rebuild with Node `26.7.0`, use absolute paths, reload systemd, and restart. | +| Local health works, phone-reachable health fails | DNS, TLS, firewall, proxy, or `listenHost` mismatch | Test from another device and repair the public route. Never proxy plugin ingress. | +| `/notifications-status` is missing | Plugin did not load, config precedence is wrong, or OpenCode was not restarted | Check the effective config, absolute package path, build output, and redacted OpenCode logs. | +| Plugin reports broker unavailable | Wrong plugin port, token path, account, permissions, or network namespace | Compare plugin options with broker config and co-locate both processes. | +| Built-in pairing fails while health passes | Scheme, hostname, port, or Basic auth does not meet its restrictions | Repair the OpenCode port allowlist or use broker CLI pairing. | +| Test push works, interaction push does not | Plugin event path failed or request predates plugin startup | Verify plugin status, restart OpenCode, and create a new request. | +| Device exists but receives no test | Delivery paused, fake mode, stale token, or internal app distribution credentials | Enable delivery and inspect service environment. If host checks pass, escalate APNs/FCM verification to the internal app distributor. | +| No foreground banner appears | Expected behavior | Retest with the app backgrounded or phone locked. | + +## Final report + +Report: + +- `complete`, `awaiting-phone`, or `blocked`. +- The installed source commit and OpenCode version. +- Which configuration files changed. +- Active port numbers and whether each is loopback or phone-reachable. +- Pass or fail for local health, phone-reachable health, plugin status, pairing, + broker test push, and a new interaction push. +- The first unverified boundary and the exact user-owned next step, if any. + +Do not report origins, addresses, usernames, paths, device IDs, credentials, +pairing codes, push tokens, secret file contents, or unredacted logs. diff --git a/docs/SPEC.md b/docs/SPEC.md index 6680d96..9820656 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -60,7 +60,8 @@ The first usable build includes: - Markdown, reasoning, tool calls, tool results, errors, and retries. - Permission and form interactions supported by the V2 contract, including string options used for question-like controls. -- Self-hosted, encrypted push notifications for permission and form attention. +- Self-hosted, encrypted push notifications for permission and form attention + and successful session completion. - Foreground/background lifecycle recovery. - Phone-first iOS and Android layouts that remain usable on tablets. @@ -118,8 +119,9 @@ truth. The optional notification broker is not an OpenCode proxy. The app still sends all OpenCode API requests directly to the saved server. A V2 plugin projects only -opaque interaction identifiers and the exact location required to route global -forms. The broker owns one-time pairing, device registrations, an encrypted +opaque routing identifiers, a finite notification category, and the exact +location required to route global forms. The broker owns one-time pairing, +device registrations, an encrypted durable outbox, Expo tickets and receipts, token replacement, and revocation. It does not use an OpenCode credential during normal notification processing. Its local pairing CLI accepts the credential and stores it in an encrypted, @@ -188,8 +190,10 @@ directly to OpenCode. - Secrets: Expo SecureStore. Never store credentials in SQLite, AsyncStorage, logs, crash reports, deep links, or analytics. - Push: `expo-notifications` in signed preview or development builds. Visible - text is generic. Routing data is encrypted per device and treated as a - volatile hint. + text comes from a finite allowlist of permission, form, completion, and test + phrases. Raw actions, resources, paths, prompts, titles, errors, and identifiers + are excluded. Routing data is encrypted per device and treated as a volatile + hint. - Transport limits: JSON responses and individual SSE events are capped at 16 MiB. Reject JSON with an oversized declared `Content-Length` before reading the body. React Native's global fetch buffers responses, so if the server omits @@ -531,10 +535,10 @@ actions. - Persist unsent drafts as encrypted content. Attachment persistence is added with the version 1.0 attachment workflow. - Do not rely on timers, sockets, or event streams continuing in the background. -- Optional remote permission and form attention uses the self-hosted OpenCode V2 - plugin and broker described in section 5. The broker contacts Expo Push - Service without retaining OpenCode credentials, and the app treats every - notification as a hint before an authoritative REST fetch. +- Optional remote permission, form, and successful session-completion alerts use + the self-hosted OpenCode V2 plugin and broker described in section 5. The broker + contacts Expo Push Service without retaining OpenCode credentials, and the app + treats every notification as a hint before an authoritative REST fetch. - Widgets and Live Activities remain optional version 1.0 work. ## 14. Reliability and performance diff --git a/packages/notification-protocol/src/index.test.ts b/packages/notification-protocol/src/index.test.ts index b4007e4..e7b3b5e 100644 --- a/packages/notification-protocol/src/index.test.ts +++ b/packages/notification-protocol/src/index.test.ts @@ -6,8 +6,10 @@ import { notificationPushAdditionalData, openNotificationJson, parseNotificationConnectionBootstrap, + parseNotificationDeliveryState, parseNotificationPairingCode, parseNotificationPairingIssueRequest, + parseNotificationPluginEvent, parseNotificationRoutingEnvelope, parseOpenCodeDevicePairingCode, sealNotificationJson, @@ -71,6 +73,66 @@ describe("notification protocol", () => { ).toThrow("INVALID_NOTIFICATION_ROUTE"); }); + it("parses session completion routes and plugin events", () => { + expect( + parseNotificationRoutingEnvelope({ + bindingID: "binding-1", + eventID: "evt_done", + expiresAtMs: 2_000, + issuedAtMs: 1_000, + kind: "session-done", + sessionID: "ses_1", + v: 1, + }), + ).toMatchObject({ kind: "session-done", sessionID: "ses_1" }); + expect( + parseNotificationPluginEvent({ + category: "session-done", + eventID: "evt_done", + kind: "session-done", + observedAtMs: 1_000, + sessionID: "ses_1", + v: 1, + }), + ).toMatchObject({ category: "session-done", kind: "session-done" }); + }); + + it("defaults old queued interactions safely and rejects arbitrary categories", () => { + expect( + parseNotificationPluginEvent({ + eventID: "evt_old", + interaction: "permission", + observedAtMs: 1_000, + requestID: "per_1", + sessionID: "ses_1", + state: "pending", + v: 1, + }), + ).toMatchObject({ category: "permission-other", kind: "interaction" }); + expect(() => + parseNotificationPluginEvent({ + category: "permission-private-action", + eventID: "evt_1", + interaction: "permission", + observedAtMs: 1_000, + requestID: "per_1", + sessionID: "ses_1", + state: "pending", + v: 1, + }), + ).toThrow("INVALID_PLUGIN_EVENT"); + expect(() => + parseNotificationPluginEvent({ + category: "permission-shell", + eventID: "evt_done", + kind: "session-done", + observedAtMs: 1_000, + sessionID: "ses_1", + v: 1, + }), + ).toThrow("INVALID_PLUGIN_EVENT"); + }); + it("validates pairing origins and key sizes", () => { const pairingSecret = encodeNotificationBytes(new Uint8Array(32).fill(7)); expect( @@ -160,4 +222,15 @@ describe("notification protocol", () => { }), ).toMatchObject({ auth: { mode: "bearer", token: " exact token " } }); }); + + it("validates shared notification delivery state", () => { + expect(parseNotificationDeliveryState({ enabled: false, updatedAtMs: 1_000, v: 1 })).toEqual({ + enabled: false, + updatedAtMs: 1_000, + v: 1, + }); + expect(() => + parseNotificationDeliveryState({ enabled: "false", updatedAtMs: 1_000, v: 1 }), + ).toThrow("INVALID_NOTIFICATION_STATE"); + }); }); diff --git a/packages/notification-protocol/src/index.ts b/packages/notification-protocol/src/index.ts index c2e5adf..a18d1a6 100644 --- a/packages/notification-protocol/src/index.ts +++ b/packages/notification-protocol/src/index.ts @@ -9,6 +9,23 @@ export const notificationPairingLifetimeMs = 2 * 60_000; export type NotificationAuthMode = "basic" | "bearer" | "none"; export type NotificationInteraction = "form" | "permission"; +export type NotificationCategory = + | "form" + | "permission-edit" + | "permission-execute" + | "permission-external-directory" + | "permission-glob" + | "permission-grep" + | "permission-other" + | "permission-question" + | "permission-read" + | "permission-shell" + | "permission-skill" + | "permission-subagent" + | "permission-webfetch" + | "permission-websearch" + | "session-done" + | "test"; export type NotificationPairingCode = { allowDevelopmentHttp: boolean; @@ -94,6 +111,15 @@ export type NotificationRoutingEnvelope = sessionID: string; v: 1; } + | { + bindingID: string; + eventID: string; + expiresAtMs: number; + issuedAtMs: number; + kind: "session-done"; + sessionID: string; + v: 1; + } | { bindingID: string; expiresAtMs: number; @@ -102,16 +128,27 @@ export type NotificationRoutingEnvelope = v: 1; }; -export type NotificationPluginEvent = { - eventID: string; - interaction: NotificationInteraction; - location?: { directory: string; workspaceID?: string }; - observedAtMs: number; - requestID: string; - sessionID: string; - state: "pending" | "resolved"; - v: 1; -}; +export type NotificationPluginEvent = + | { + category: Exclude; + eventID: string; + interaction: NotificationInteraction; + kind: "interaction"; + location?: { directory: string; workspaceID?: string }; + observedAtMs: number; + requestID: string; + sessionID: string; + state: "pending" | "resolved"; + v: 1; + } + | { + category: "session-done"; + eventID: string; + kind: "session-done"; + observedAtMs: number; + sessionID: string; + v: 1; + }; export type NotificationDeviceRequest = { bindingID: string; @@ -124,7 +161,13 @@ export type NotificationDeviceCommand = { atMs: number; expoPushToken?: string; nonceID: string; - operation: "revoke" | "status" | "test" | "token"; + operation: "enable" | "pause" | "revoke" | "status" | "test" | "token"; + v: 1; +}; + +export type NotificationDeliveryState = { + enabled: boolean; + updatedAtMs: number; v: 1; }; @@ -304,6 +347,14 @@ export function parseNotificationRoutingEnvelope(value: unknown): NotificationRo v: 1 as const, }; if (value.kind === "test") return { ...base, kind: "test" }; + if (value.kind === "session-done") { + return { + ...base, + eventID: parseIdentifier(value.eventID, 160, "INVALID_NOTIFICATION_ROUTE"), + kind: "session-done", + sessionID: parseSessionID(value.sessionID, "INVALID_NOTIFICATION_ROUTE"), + }; + } if (value.kind !== "interaction") throw new Error("INVALID_NOTIFICATION_ROUTE"); const interaction = parseInteraction(value.interaction, "INVALID_NOTIFICATION_ROUTE"); const sessionID = parseSessionOwner(value.sessionID, "INVALID_NOTIFICATION_ROUTE"); @@ -324,6 +375,19 @@ export function parseNotificationRoutingEnvelope(value: unknown): NotificationRo export function parseNotificationPluginEvent(value: unknown): NotificationPluginEvent { if (!isRecord(value) || value.v !== 1) throw new Error("INVALID_PLUGIN_EVENT"); + if (value.kind === "session-done") { + if (value.category !== undefined && value.category !== "session-done") { + throw new Error("INVALID_PLUGIN_EVENT"); + } + return { + category: "session-done", + eventID: parseIdentifier(value.eventID, 160, "INVALID_PLUGIN_EVENT"), + kind: "session-done", + observedAtMs: parseTimestamp(value.observedAtMs, "INVALID_PLUGIN_EVENT"), + sessionID: parseSessionID(value.sessionID, "INVALID_PLUGIN_EVENT"), + v: 1, + }; + } const interaction = parseInteraction(value.interaction, "INVALID_PLUGIN_EVENT"); const state = value.state; if (state !== "pending" && state !== "resolved") throw new Error("INVALID_PLUGIN_EVENT"); @@ -333,8 +397,10 @@ export function parseNotificationPluginEvent(value: unknown): NotificationPlugin throw new Error("INVALID_PLUGIN_EVENT"); } return { + category: parseInteractionCategory(value.category, interaction, "INVALID_PLUGIN_EVENT"), eventID: parseIdentifier(value.eventID, 160, "INVALID_PLUGIN_EVENT"), interaction, + kind: "interaction", ...(location ? { location } : {}), observedAtMs: parseTimestamp(value.observedAtMs, "INVALID_PLUGIN_EVENT"), requestID: parseRequestID(value.requestID, interaction, "INVALID_PLUGIN_EVENT"), @@ -360,6 +426,8 @@ export function parseNotificationDeviceCommand(value: unknown): NotificationDevi if (!isRecord(value) || value.v !== 1) throw new Error("INVALID_DEVICE_COMMAND"); const operation = value.operation; if ( + operation !== "enable" && + operation !== "pause" && operation !== "revoke" && operation !== "status" && operation !== "test" && @@ -378,6 +446,15 @@ export function parseNotificationDeviceCommand(value: unknown): NotificationDevi }; } +export function parseNotificationDeliveryState(value: unknown): NotificationDeliveryState { + if (!isRecord(value) || value.v !== 1) throw new Error("INVALID_NOTIFICATION_STATE"); + return { + enabled: parseBoolean(value.enabled, "INVALID_NOTIFICATION_STATE"), + updatedAtMs: parseTimestamp(value.updatedAtMs, "INVALID_NOTIFICATION_STATE"), + v: 1, + }; +} + export function sealNotificationJson( key: Uint8Array, nonce: Uint8Array, @@ -466,6 +543,42 @@ function parseSessionOwner(value: unknown, error: string) { return id; } +function parseSessionID(value: unknown, error: string) { + const id = parseIdentifier(value, 160, error); + if (!id.startsWith("ses")) throw new Error(error); + return id; +} + +function parseInteractionCategory( + value: unknown, + interaction: NotificationInteraction, + error: string, +): Exclude { + if (interaction === "form") { + if (value !== undefined && value !== "form") throw new Error(error); + return "form"; + } + if (value === undefined) return "permission-other"; + if ( + value !== "permission-edit" && + value !== "permission-execute" && + value !== "permission-external-directory" && + value !== "permission-glob" && + value !== "permission-grep" && + value !== "permission-other" && + value !== "permission-question" && + value !== "permission-read" && + value !== "permission-shell" && + value !== "permission-skill" && + value !== "permission-subagent" && + value !== "permission-webfetch" && + value !== "permission-websearch" + ) { + throw new Error(error); + } + return value; +} + function parseExpoPushToken(value: unknown, error: string) { const token = parseText(value, 512, error); if (!/^(Exponent|Expo)PushToken\[[A-Za-z0-9_-]+\]$/.test(token)) throw new Error(error); diff --git a/packages/opencode-notification-plugin/package.json b/packages/opencode-notification-plugin/package.json index 6cc8fcd..4118255 100644 --- a/packages/opencode-notification-plugin/package.json +++ b/packages/opencode-notification-plugin/package.json @@ -8,6 +8,11 @@ "types": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./tui": { + "types": "./src/tui.ts", + "import": "./dist/tui.js", + "default": "./dist/tui.js" } }, "scripts": { diff --git a/packages/opencode-notification-plugin/src/broker.test.ts b/packages/opencode-notification-plugin/src/broker.test.ts new file mode 100644 index 0000000..1ab94ac --- /dev/null +++ b/packages/opencode-notification-plugin/src/broker.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { requestNotificationDeliveryState } from "./broker.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("notification broker controls", () => { + it("uses the authenticated loopback control endpoint", async () => { + const fetch = vi.fn(async () => Response.json({ enabled: false, updatedAtMs: 1_000, v: 1 })); + globalThis.fetch = fetch; + + await expect( + requestNotificationDeliveryState( + { brokerOrigin: "http://127.0.0.1:37101", ingestToken: "test-token" }, + "pause", + ), + ).resolves.toEqual({ enabled: false, updatedAtMs: 1_000, v: 1 }); + expect(fetch).toHaveBeenCalledWith( + "http://127.0.0.1:37101/v1/plugin/pause", + expect.objectContaining({ + headers: { Authorization: "Bearer test-token" }, + method: "POST", + }), + ); + }); +}); diff --git a/packages/opencode-notification-plugin/src/broker.ts b/packages/opencode-notification-plugin/src/broker.ts new file mode 100644 index 0000000..ad02dd8 --- /dev/null +++ b/packages/opencode-notification-plugin/src/broker.ts @@ -0,0 +1,44 @@ +import { readFile } from "node:fs/promises"; + +import { + type NotificationDeliveryState, + parseNotificationDeliveryState, +} from "@opencode2-mobile/notification-protocol"; + +export type BrokerAccess = { brokerOrigin: string; ingestToken: string }; + +export async function readBrokerAccess(options: Readonly>) { + const brokerOrigin = options.brokerOrigin; + const tokenFile = options.tokenFile; + if (typeof brokerOrigin !== "string" || typeof tokenFile !== "string") { + throw new Error("NOTIFICATION_PLUGIN_OPTIONS_REQUIRED"); + } + const url = new URL(brokerOrigin); + if ( + url.protocol !== "http:" || + (url.hostname !== "127.0.0.1" && url.hostname !== "localhost" && url.hostname !== "::1") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error("INVALID_NOTIFICATION_BROKER_ORIGIN"); + } + const ingestToken = (await readFile(tokenFile, "utf8")).trim(); + if (!/^[A-Za-z0-9_-]{40,100}$/.test(ingestToken)) throw new Error("INVALID_INGEST_TOKEN"); + return { brokerOrigin: url.origin, ingestToken } satisfies BrokerAccess; +} + +export async function requestNotificationDeliveryState( + access: BrokerAccess, + operation: "enable" | "pause" | "status", +): Promise { + const response = await fetch(`${access.brokerOrigin}/v1/plugin/${operation}`, { + headers: { Authorization: `Bearer ${access.ingestToken}` }, + method: operation === "status" ? "GET" : "POST", + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) throw new Error("NOTIFICATION_BROKER_UNAVAILABLE"); + return parseNotificationDeliveryState(await response.json()); +} diff --git a/packages/opencode-notification-plugin/src/index.test.ts b/packages/opencode-notification-plugin/src/index.test.ts index 262cafc..066a834 100644 --- a/packages/opencode-notification-plugin/src/index.test.ts +++ b/packages/opencode-notification-plugin/src/index.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { normalizeOpenCodeNotificationEvent } from "./index.js"; +import notificationPlugin, { normalizeOpenCodeNotificationEvent } from "./index.js"; describe("notification plugin projection", () => { + it("loads the matching TUI controls", () => { + expect(notificationPlugin.tui).toBe(true); + }); + it("projects permission events without request content", () => { expect( normalizeOpenCodeNotificationEvent({ @@ -17,8 +21,10 @@ describe("notification plugin projection", () => { type: "permission.asked", }), ).toEqual({ + category: "permission-shell", eventID: "evt_1", interaction: "permission", + kind: "interaction", observedAtMs: 1_000, requestID: "per_1", sessionID: "ses_1", @@ -27,6 +33,40 @@ describe("notification plugin projection", () => { }); }); + it("uses a generic category for unknown permission actions", () => { + expect( + normalizeOpenCodeNotificationEvent({ + created: 1_000, + data: { action: "private-plugin-action", id: "per_1", sessionID: "ses_1" }, + id: "evt_1", + type: "permission.asked", + }), + ).toMatchObject({ category: "permission-other", kind: "interaction" }); + }); + + it.each([ + ["edit", "permission-edit"], + ["execute", "permission-execute"], + ["external_directory", "permission-external-directory"], + ["glob", "permission-glob"], + ["grep", "permission-grep"], + ["question", "permission-question"], + ["read", "permission-read"], + ["skill", "permission-skill"], + ["subagent", "permission-subagent"], + ["webfetch", "permission-webfetch"], + ["websearch", "permission-websearch"], + ])("maps the %s action to %s", (action, category) => { + expect( + normalizeOpenCodeNotificationEvent({ + created: 1_000, + data: { action, id: "per_1", sessionID: "ses_1" }, + id: "evt_1", + type: "permission.asked", + }), + ).toMatchObject({ category }); + }); + it("retains only the location needed by a global form", () => { expect( normalizeOpenCodeNotificationEvent({ @@ -37,11 +77,47 @@ describe("notification plugin projection", () => { type: "form.created", }), ).toMatchObject({ + category: "form", interaction: "form", + kind: "interaction", location: { directory: "/workspace" }, requestID: "frm_1", sessionID: "global", state: "pending", }); }); + + it("projects only successful execution completion", () => { + expect( + normalizeOpenCodeNotificationEvent({ + created: 1_000, + data: { sessionID: "ses_1" }, + id: "evt_done", + type: "session.execution.succeeded", + }), + ).toEqual({ + category: "session-done", + eventID: "evt_done", + kind: "session-done", + observedAtMs: 1_000, + sessionID: "ses_1", + v: 1, + }); + expect( + normalizeOpenCodeNotificationEvent({ + created: 1_001, + data: { sessionID: "ses_1", status: { type: "idle" } }, + id: "evt_idle", + type: "session.status", + }), + ).toBeUndefined(); + expect( + normalizeOpenCodeNotificationEvent({ + created: 1_002, + data: { sessionID: "ses_1" }, + id: "evt_failed", + type: "session.execution.failed", + }), + ).toBeUndefined(); + }); }); diff --git a/packages/opencode-notification-plugin/src/index.ts b/packages/opencode-notification-plugin/src/index.ts index 314c982..7b15c26 100644 --- a/packages/opencode-notification-plugin/src/index.ts +++ b/packages/opencode-notification-plugin/src/index.ts @@ -1,21 +1,21 @@ -import { readFile } from "node:fs/promises"; - import { Plugin } from "@opencode-ai/plugin"; import { + type NotificationCategory, type NotificationPluginEvent, parseNotificationPluginEvent, } from "@opencode2-mobile/notification-protocol"; +import { readBrokerAccess } from "./broker.js"; + const queueStorageKey = "notification-outbox-v1"; const droppedStorageKey = "notification-outbox-dropped-v1"; const maximumQueueSize = 1_000; export default Plugin.define({ id: "opencode-mobile-notifications", + tui: true, async setup(ctx) { - const options = parseOptions(ctx.options); - const ingestToken = (await readFile(options.tokenFile, "utf8")).trim(); - if (!/^[A-Za-z0-9_-]{40,100}$/.test(ingestToken)) throw new Error("INVALID_INGEST_TOKEN"); + const access = await readBrokerAccess(ctx.options); let queue = parseStoredQueue(await ctx.storage.get(queueStorageKey)); let droppedEvents = parseDroppedCount(await ctx.storage.get(droppedStorageKey)); let stopped = false; @@ -37,10 +37,10 @@ export default Plugin.define({ const batch = queue.slice(0, 100); let response: Response; try { - response = await fetch(`${options.brokerOrigin}/v1/plugin/events`, { + response = await fetch(`${access.brokerOrigin}/v1/plugin/events`, { body: JSON.stringify({ events: batch, v: 1 }), headers: { - Authorization: `Bearer ${ingestToken}`, + Authorization: `Bearer ${access.ingestToken}`, "Content-Type": "application/json", }, method: "POST", @@ -60,12 +60,20 @@ export default Plugin.define({ }; const enqueue = async (event: NotificationPluginEvent) => { if ( - queue.some((queued) => queued.eventID === event.eventID && queued.state === event.state) + queue.some( + (queued) => + queued.eventID === event.eventID && + (queued.kind === "session-done" || + event.kind === "session-done" || + queued.state === event.state), + ) ) { return; } if (queue.length >= maximumQueueSize) { - const resolvedIndex = queue.findIndex((queued) => queued.state === "resolved"); + const resolvedIndex = queue.findIndex( + (queued) => queued.kind === "interaction" && queued.state === "resolved", + ); queue = queue.toSpliced(resolvedIndex >= 0 ? resolvedIndex : 0, 1); droppedEvents += 1; await ctx.storage.set(droppedStorageKey, droppedEvents); @@ -110,8 +118,10 @@ export function normalizeOpenCodeNotificationEvent( const location = parseEventLocation(value.location); if (value.type === "permission.asked") { return safePluginEvent({ + category: permissionCategory(data.action), eventID: value.id, interaction: "permission", + kind: "interaction", observedAtMs: value.created, requestID: data.id, sessionID: data.sessionID, @@ -121,8 +131,10 @@ export function normalizeOpenCodeNotificationEvent( } if (value.type === "permission.replied") { return safePluginEvent({ + category: "permission-other", eventID: value.id, interaction: "permission", + kind: "interaction", observedAtMs: value.created, requestID: data.requestID, sessionID: data.sessionID, @@ -133,8 +145,10 @@ export function normalizeOpenCodeNotificationEvent( if (value.type === "form.created" && isRecord(data.form)) { const sessionID = data.form.sessionID; return safePluginEvent({ + category: "form", eventID: value.id, interaction: "form", + kind: "interaction", ...(sessionID === "global" && location ? { location } : {}), observedAtMs: value.created, requestID: data.form.id, @@ -146,8 +160,10 @@ export function normalizeOpenCodeNotificationEvent( if (value.type === "form.replied" || value.type === "form.cancelled") { const sessionID = data.sessionID; return safePluginEvent({ + category: "form", eventID: value.id, interaction: "form", + kind: "interaction", ...(sessionID === "global" && location ? { location } : {}), observedAtMs: value.created, requestID: data.id, @@ -156,9 +172,40 @@ export function normalizeOpenCodeNotificationEvent( v: 1, }); } + if (value.type === "session.execution.succeeded") { + return safePluginEvent({ + category: "session-done", + eventID: value.id, + kind: "session-done", + observedAtMs: value.created, + sessionID: data.sessionID, + v: 1, + }); + } return undefined; } +const permissionCategories = new Map([ + ["edit", "permission-edit"], + ["execute", "permission-execute"], + ["external_directory", "permission-external-directory"], + ["glob", "permission-glob"], + ["grep", "permission-grep"], + ["question", "permission-question"], + ["read", "permission-read"], + ["shell", "permission-shell"], + ["skill", "permission-skill"], + ["subagent", "permission-subagent"], + ["webfetch", "permission-webfetch"], + ["websearch", "permission-websearch"], +]); + +function permissionCategory(value: unknown) { + return typeof value === "string" + ? (permissionCategories.get(value) ?? "permission-other") + : "permission-other"; +} + function safePluginEvent(value: unknown) { try { return parseNotificationPluginEvent(value); @@ -188,27 +235,6 @@ function parseEventLocation(value: unknown) { }; } -function parseOptions(value: Readonly>) { - const brokerOrigin = value.brokerOrigin; - const tokenFile = value.tokenFile; - if (typeof brokerOrigin !== "string" || typeof tokenFile !== "string") { - throw new Error("NOTIFICATION_PLUGIN_OPTIONS_REQUIRED"); - } - const url = new URL(brokerOrigin); - if ( - url.protocol !== "http:" || - (url.hostname !== "127.0.0.1" && url.hostname !== "localhost" && url.hostname !== "::1") || - url.username || - url.password || - url.pathname !== "/" || - url.search || - url.hash - ) { - throw new Error("INVALID_NOTIFICATION_BROKER_ORIGIN"); - } - return { brokerOrigin: url.origin, tokenFile }; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/opencode-notification-plugin/src/tui.ts b/packages/opencode-notification-plugin/src/tui.ts new file mode 100644 index 0000000..0c63919 --- /dev/null +++ b/packages/opencode-notification-plugin/src/tui.ts @@ -0,0 +1,59 @@ +import { Plugin } from "@opencode-ai/plugin/tui"; + +import { readBrokerAccess, requestNotificationDeliveryState } from "./broker.js"; + +export default Plugin.define({ + id: "opencode-mobile-notifications", + async setup(context) { + const access = await readBrokerAccess(context.options); + const run = async (operation: "enable" | "pause" | "status") => { + try { + const state = await requestNotificationDeliveryState(access, operation); + context.ui.toast.show({ + message: `Mobile notifications ${state.enabled ? "enabled" : "paused"}`, + variant: "success", + }); + } catch { + context.ui.toast.show({ + message: "The notification broker could not be reached", + variant: "error", + }); + } + }; + return context.ui.slot({ + append: "sidebar.footer", + render() { + context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + group: "Notifications", + id: "opencode-mobile-notifications.status", + palette: true, + run: () => run("status"), + slash: { name: "notifications-status" }, + title: "Show mobile notification status", + }, + { + group: "Notifications", + id: "opencode-mobile-notifications.pause", + palette: true, + run: () => run("pause"), + slash: { name: "notifications-pause" }, + title: "Pause mobile notifications", + }, + { + group: "Notifications", + id: "opencode-mobile-notifications.enable", + palette: true, + run: () => run("enable"), + slash: { name: "notifications-enable" }, + title: "Enable mobile notifications", + }, + ], + })); + return null; + }, + }); + }, +});