feat: add presence-gated Expo push notifications

This commit is contained in:
Mohamed Boudra
2026-02-03 12:59:00 +07:00
parent ad632cb3bd
commit af89de1237
7 changed files with 332 additions and 122 deletions
+61 -2
View File
@@ -1,5 +1,5 @@
import "@/styles/unistyles"; import "@/styles/unistyles";
import { Stack, usePathname } from "expo-router"; import { Stack, usePathname, useRouter } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -15,9 +15,10 @@ import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-reg
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context"; import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo } from "react"; import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { SlidingSidebar } from "@/components/sliding-sidebar"; import { SlidingSidebar } from "@/components/sliding-sidebar";
import { DownloadToast } from "@/components/download-toast"; import { DownloadToast } from "@/components/download-toast";
import { ToastProvider } from "@/contexts/toast-context"; import { ToastProvider } from "@/contexts/toast-context";
@@ -34,6 +35,63 @@ import {
import { getIsTauriMac } from "@/constants/layout"; import { getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window"; import { useTrafficLightPadding } from "@/utils/tauri-window";
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => {
if (Platform.OS === "web") {
return;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
const agentId = typeof data?.agentId === "string" ? data.agentId : null;
if (agentId) {
// Legacy route resolves agent -> host once sessions reconnect.
router.push(`/agent/${agentId}` as any);
} else {
router.push("/agents" as any);
}
};
const subscription =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
});
return () => {
subscription.remove();
};
}, [router]);
return null;
}
function QueryProvider({ children }: { children: ReactNode }) { function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
@@ -335,6 +393,7 @@ export default function RootLayout() {
<QueryProvider> <QueryProvider>
<DaemonRegistryProvider> <DaemonRegistryProvider>
<DaemonConnectionsProvider> <DaemonConnectionsProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost /> <MultiDaemonSessionHost />
<ProvidersWrapper> <ProvidersWrapper>
<SidebarAnimationProvider> <SidebarAnimationProvider>
@@ -13,6 +13,7 @@ import { useMutation } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client"; import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { import {
applyStreamEvent, applyStreamEvent,
generateMessageId, generateMessageId,
@@ -388,6 +389,7 @@ export function SessionProvider({
// Client activity tracking (heartbeat, push token registration) // Client activity tracking (heartbeat, push token registration)
useClientActivity({ client, focusedAgentId }); useClientActivity({ client, focusedAgentId });
usePushTokenRegistration({ client, serverId });
// State for voice detection flags (will be set by RealtimeContext) // State for voice detection flags (will be set by RealtimeContext)
const isDetectingRef = useRef(false); const isDetectingRef = useRef(false);
+55 -23
View File
@@ -3,6 +3,7 @@ import { AppState, Platform } from "react-native";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
interface ClientActivityOptions { interface ClientActivityOptions {
client: DaemonClientV2; client: DaemonClientV2;
@@ -12,14 +13,15 @@ interface ClientActivityOptions {
/** /**
* Handles client activity reporting: * Handles client activity reporting:
* - Heartbeat sending every 15 seconds * - Heartbeat sending every 15 seconds
* - App visibility tracking (updates lastActivityAt on foreground) * - App visibility tracking
* - Sends heartbeat immediately when focused agent changes * - Records lastActivityAt only on real user activity (not on heartbeat)
*/ */
export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void { export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void {
const lastActivityAtRef = useRef<Date>(new Date()); const lastActivityAtRef = useRef<Date>(new Date());
const appVisibleRef = useRef(AppState.currentState === "active"); const appVisibleRef = useRef(AppState.currentState === "active");
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId); const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile"; const deviceType = Platform.OS === "web" ? "web" : "mobile";
@@ -28,17 +30,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
}, []); }, []);
const sendHeartbeat = useCallback(() => { const sendHeartbeat = useCallback(() => {
if (!client.isConnected) { if (!client.isConnected) return;
console.log("[ClientActivity] sendHeartbeat skipped - not connected");
return;
}
lastActivityAtRef.current = new Date();
console.log("[ClientActivity] sendHeartbeat", {
deviceType,
focusedAgentId,
lastActivityAt: lastActivityAtRef.current.toISOString(),
appVisible: appVisibleRef.current,
});
client.sendHeartbeat({ client.sendHeartbeat({
deviceType, deviceType,
focusedAgentId, focusedAgentId,
@@ -47,35 +39,77 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
}); });
}, [client, deviceType, focusedAgentId]); }, [client, deviceType, focusedAgentId]);
const maybeSendImmediateHeartbeat = useCallback(() => {
if (!client.isConnected) return;
const now = Date.now();
if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) {
return;
}
lastImmediateHeartbeatAtRef.current = now;
sendHeartbeat();
}, [client, sendHeartbeat]);
// Track app visibility // Track app visibility
useEffect(() => { useEffect(() => {
console.log("[ClientActivity] AppState effect mounted, current:", AppState.currentState);
const subscription = AppState.addEventListener("change", (nextState) => { const subscription = AppState.addEventListener("change", (nextState) => {
console.log("[ClientActivity] AppState changed:", nextState);
appVisibleRef.current = nextState === "active"; appVisibleRef.current = nextState === "active";
if (nextState === "active") { if (nextState === "active") {
recordUserActivity(); recordUserActivity();
maybeSendImmediateHeartbeat();
} }
}); });
return () => subscription.remove(); return () => subscription.remove();
}, [recordUserActivity]); }, [maybeSendImmediateHeartbeat, recordUserActivity]);
// Track user activity on web for accurate staleness.
useEffect(() => {
if (Platform.OS !== "web") return;
if (typeof document === "undefined") return;
const handleUserActivity = () => {
recordUserActivity();
maybeSendImmediateHeartbeat();
};
const handleVisibilityChange = () => {
const visible = document.visibilityState === "visible";
appVisibleRef.current = visible;
if (visible) {
recordUserActivity();
maybeSendImmediateHeartbeat();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleUserActivity);
window.addEventListener("pointerdown", handleUserActivity, { passive: true });
window.addEventListener("keydown", handleUserActivity);
window.addEventListener("wheel", handleUserActivity, { passive: true });
window.addEventListener("touchstart", handleUserActivity, { passive: true });
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleUserActivity);
window.removeEventListener("pointerdown", handleUserActivity);
window.removeEventListener("keydown", handleUserActivity);
window.removeEventListener("wheel", handleUserActivity);
window.removeEventListener("touchstart", handleUserActivity);
};
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
// Send heartbeat on focused agent change // Send heartbeat on focused agent change
useEffect(() => { useEffect(() => {
if (prevFocusedAgentIdRef.current !== focusedAgentId) { if (prevFocusedAgentIdRef.current !== focusedAgentId) {
console.log("[ClientActivity] focusedAgentId changed:", prevFocusedAgentIdRef.current, "->", focusedAgentId);
prevFocusedAgentIdRef.current = focusedAgentId; prevFocusedAgentIdRef.current = focusedAgentId;
recordUserActivity();
sendHeartbeat(); sendHeartbeat();
} }
}, [focusedAgentId, sendHeartbeat]); }, [focusedAgentId, recordUserActivity, sendHeartbeat]);
// Periodic heartbeat // Periodic heartbeat
useEffect(() => { useEffect(() => {
console.log("[ClientActivity] Heartbeat effect mounted, isConnected:", client.isConnected);
const startHeartbeat = () => { const startHeartbeat = () => {
console.log("[ClientActivity] startHeartbeat called");
if (heartbeatIntervalRef.current) { if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current); clearInterval(heartbeatIntervalRef.current);
} }
@@ -84,7 +118,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
}; };
const stopHeartbeat = () => { const stopHeartbeat = () => {
console.log("[ClientActivity] stopHeartbeat called");
if (heartbeatIntervalRef.current) { if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current); clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = null; heartbeatIntervalRef.current = null;
@@ -92,7 +125,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
}; };
const unsubscribe = client.subscribeConnectionStatus((state) => { const unsubscribe = client.subscribeConnectionStatus((state) => {
console.log("[ClientActivity] Connection status changed:", state.status);
if (state.status === "connected") { if (state.status === "connected") {
startHeartbeat(); startHeartbeat();
} else { } else {
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
const STORAGE_PREFIX = "@paseo:expo-push-token:";
function getExpoProjectId(): string | null {
const fromEas = (Constants as any)?.easConfig?.projectId;
if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim();
const fromExtra = (Constants as any)?.expoConfig?.extra?.eas?.projectId;
if (typeof fromExtra === "string" && fromExtra.trim()) return fromExtra.trim();
return null;
}
async function ensurePushPermission(): Promise<boolean> {
const existing = await Notifications.getPermissionsAsync();
if (existing.status === "granted") return true;
if (!existing.canAskAgain) return false;
const requested = await Notifications.requestPermissionsAsync();
return requested.status === "granted";
}
export function usePushTokenRegistration(params: {
client: DaemonClientV2;
serverId: string;
}): void {
const { client, serverId } = params;
const tokenRef = useRef<string | null>(null);
const lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return;
if (!client.isConnected) return;
const token = tokenRef.current;
if (!token) return;
if (lastSentTokenRef.current === token) return;
lastSentTokenRef.current = token;
client.registerPushToken(token);
}, [client]);
useEffect(() => {
if (Platform.OS === "web") return;
const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false;
const run = async () => {
const cached = await AsyncStorage.getItem(storageKey);
if (cancelled) return;
if (cached && typeof cached === "string") {
tokenRef.current = cached;
}
const granted = await ensurePushPermission();
if (!granted || cancelled) return;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const projectId = getExpoProjectId();
if (!projectId) {
console.warn("[PushToken] Missing EAS projectId; cannot fetch Expo push token");
return;
}
const result = await Notifications.getExpoPushTokenAsync({ projectId });
if (cancelled) return;
const token = result.data;
if (typeof token !== "string" || !token.trim()) return;
tokenRef.current = token;
await AsyncStorage.setItem(storageKey, token);
await registerIfPossible();
};
void run().catch((error) => {
console.warn("[PushToken] Failed to register push token", error);
});
return () => {
cancelled = true;
};
}, [registerIfPossible, serverId]);
useEffect(() => {
const unsubscribe = client.subscribeConnectionStatus((state) => {
if (state.status === "connected") {
void registerIfPossible();
} else {
// Re-register on the next successful connect.
lastSentTokenRef.current = null;
}
});
if (client.isConnected) {
void registerIfPossible();
}
return unsubscribe;
}, [client, registerIfPossible]);
}
+20 -85
View File
@@ -1,5 +1,4 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
type OsNotificationPayload = { type OsNotificationPayload = {
title: string; title: string;
@@ -7,8 +6,6 @@ type OsNotificationPayload = {
data?: Record<string, unknown>; data?: Record<string, unknown>;
}; };
let isNativeConfigured = false;
let permissionState: "unknown" | "granted" | "denied" = "unknown";
let permissionRequest: Promise<boolean> | null = null; let permissionRequest: Promise<boolean> | null = null;
function getWebNotificationConstructor(): { function getWebNotificationConstructor(): {
@@ -20,73 +17,25 @@ function getWebNotificationConstructor(): {
return NotificationConstructor ?? null; return NotificationConstructor ?? null;
} }
async function configureNativeNotifications(): Promise<void> {
if (isNativeConfigured || Platform.OS === "web") {
return;
}
isNativeConfigured = true;
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
}
async function ensureNotificationPermission(): Promise<boolean> { async function ensureNotificationPermission(): Promise<boolean> {
if (Platform.OS === "web") { const NotificationConstructor = getWebNotificationConstructor();
const NotificationConstructor = getWebNotificationConstructor(); if (!NotificationConstructor) {
if (!NotificationConstructor) { return false;
return false;
}
if (NotificationConstructor.permission === "granted") {
return true;
}
if (NotificationConstructor.permission === "denied") {
return false;
}
if (permissionRequest) {
return permissionRequest;
}
permissionRequest = Promise.resolve(
NotificationConstructor.requestPermission
? NotificationConstructor.requestPermission()
: "denied"
).then((permission) => permission === "granted");
const result = await permissionRequest;
permissionRequest = null;
return result;
} }
if (NotificationConstructor.permission === "granted") {
if (permissionState === "granted") {
return true; return true;
} }
if (permissionState === "denied") { if (NotificationConstructor.permission === "denied") {
return false; return false;
} }
if (permissionRequest) { if (permissionRequest) {
return permissionRequest; return permissionRequest;
} }
permissionRequest = Promise.resolve(
permissionRequest = (async () => { NotificationConstructor.requestPermission
const existing = await Notifications.getPermissionsAsync(); ? NotificationConstructor.requestPermission()
if (existing.status === "granted") { : "denied"
permissionState = "granted"; ).then((permission) => permission === "granted");
return true;
}
if (!existing.canAskAgain) {
permissionState = "denied";
return false;
}
const requested = await Notifications.requestPermissionsAsync();
permissionState = requested.status === "granted" ? "granted" : "denied";
return permissionState === "granted";
})();
const result = await permissionRequest; const result = await permissionRequest;
permissionRequest = null; permissionRequest = null;
return result; return result;
@@ -95,36 +44,22 @@ async function ensureNotificationPermission(): Promise<boolean> {
export async function sendOsNotification( export async function sendOsNotification(
payload: OsNotificationPayload payload: OsNotificationPayload
): Promise<boolean> { ): Promise<boolean> {
if (Platform.OS === "web") { // Mobile/native notifications should be remote push only.
const NotificationConstructor = getWebNotificationConstructor(); if (Platform.OS !== "web") {
if (!NotificationConstructor) { return false;
return false;
}
const granted = await ensureNotificationPermission();
if (!granted) {
return false;
}
new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
});
return true;
} }
await configureNativeNotifications(); const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
return false;
}
const granted = await ensureNotificationPermission(); const granted = await ensureNotificationPermission();
if (!granted) { if (!granted) {
return false; return false;
} }
new NotificationConstructor(payload.title, {
await Notifications.scheduleNotificationAsync({ body: payload.body,
content: { data: payload.data,
title: payload.title,
body: payload.body,
data: payload.data,
},
trigger: null,
}); });
return true; return true;
} }
+49 -5
View File
@@ -1,25 +1,38 @@
import type pino from "pino"; import type pino from "pino";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
/** /**
* Simple in-memory store for Expo push tokens. * Store for Expo push tokens.
* Tokens are used to send push notifications when all clients are stale. *
* Tokens are persisted to disk so pushes still work after daemon restarts.
*/ */
export class PushTokenStore { export class PushTokenStore {
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
private tokens: Set<string> = new Set(); private tokens: Set<string> = new Set();
private readonly filePath: string;
constructor(logger: pino.Logger) { constructor(logger: pino.Logger, filePath: string) {
this.logger = logger.child({ component: "token-store" }); this.logger = logger.child({ component: "token-store" });
this.filePath = filePath;
this.loadFromDisk();
} }
addToken(token: string): void { addToken(token: string): void {
this.tokens.add(token); const normalized = token.trim();
if (!normalized) return;
if (this.tokens.has(normalized)) return;
this.tokens.add(normalized);
this.persist();
this.logger.debug({ total: this.tokens.size }, "Added token"); this.logger.debug({ total: this.tokens.size }, "Added token");
} }
removeToken(token: string): void { removeToken(token: string): void {
const deleted = this.tokens.delete(token); const normalized = token.trim();
if (!normalized) return;
const deleted = this.tokens.delete(normalized);
if (deleted) { if (deleted) {
this.persist();
this.logger.debug({ total: this.tokens.size }, "Removed token"); this.logger.debug({ total: this.tokens.size }, "Removed token");
} }
} }
@@ -27,4 +40,35 @@ export class PushTokenStore {
getAllTokens(): string[] { getAllTokens(): string[] {
return Array.from(this.tokens); return Array.from(this.tokens);
} }
private loadFromDisk(): void {
try {
if (!existsSync(this.filePath)) {
return;
}
const raw = readFileSync(this.filePath, "utf-8");
const parsed = JSON.parse(raw) as { tokens?: unknown };
const tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter((t): t is string => typeof t === "string" && t.trim().length > 0)
: [];
this.tokens = new Set(tokens.map((t) => t.trim()));
this.logger.info({ total: this.tokens.size }, "Loaded push tokens");
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to load push tokens");
}
}
private persist(): void {
try {
mkdirSync(dirname(this.filePath), { recursive: true });
const tmpPath = `${this.filePath}.tmp`;
const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n";
writeFileSync(tmpPath, payload);
renameSync(tmpPath, this.filePath);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to persist push tokens");
}
}
} }
@@ -71,7 +71,10 @@ export class WebSocketSessionBridge {
this.dictation = dictation ?? null; this.dictation = dictation ?? null;
const pushLogger = this.logger.child({ module: "push" }); const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger); this.pushTokenStore = new PushTokenStore(
pushLogger,
join(paseoHome, "push-tokens.json")
);
this.pushService = new PushService(pushLogger, this.pushTokenStore); this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => { this.agentManager.setAgentAttentionCallback((params) => {
@@ -391,16 +394,42 @@ export class WebSocketSessionBridge {
"broadcastAgentAttention" "broadcastAgentAttention"
); );
const allClientsStale = allStates.every((state) => state.isStale); const hasActiveWebClient = allStates.some(
this.logger.debug({ allClientsStale }, "Client staleness check"); (state) => state.deviceType === "web" && !state.isStale
if (allClientsStale) { );
const hasActiveMobileForegroundClient = allStates.some(
(state) => state.deviceType === "mobile" && state.appVisible && !state.isStale
);
// Push is only a fallback when the user is away from their desktop/web.
// Also suppress push if they're actively using the mobile app.
const shouldSendPush =
params.reason !== "error" &&
!hasActiveWebClient &&
!hasActiveMobileForegroundClient;
this.logger.debug(
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
"Push gating check"
);
if (shouldSendPush) {
const tokens = this.pushTokenStore.getAllTokens(); const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification"); this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length > 0) { if (tokens.length > 0) {
const agent = this.agentManager.getAgent(params.agentId);
const agentTitle = agent?.config?.title ?? agent?.cwd ?? params.agentId;
const title =
params.reason === "permission" ? "Agent needs permission" : "Agent finished";
const body =
params.reason === "permission"
? `Permission requested: ${agentTitle}`
: `Finished: ${agentTitle}`;
void this.pushService.sendPush(tokens, { void this.pushService.sendPush(tokens, {
title: "Agent needs attention", title,
body: `Reason: ${params.reason}`, body,
data: { agentId: params.agentId }, data: { agentId: params.agentId, reason: params.reason },
}); });
} }
} }