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
+49 -5
View File
@@ -1,25 +1,38 @@
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.
* Tokens are used to send push notifications when all clients are stale.
* Store for Expo push tokens.
*
* Tokens are persisted to disk so pushes still work after daemon restarts.
*/
export class PushTokenStore {
private readonly logger: pino.Logger;
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.filePath = filePath;
this.loadFromDisk();
}
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");
}
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) {
this.persist();
this.logger.debug({ total: this.tokens.size }, "Removed token");
}
}
@@ -27,4 +40,35 @@ export class PushTokenStore {
getAllTokens(): string[] {
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;
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.agentManager.setAgentAttentionCallback((params) => {
@@ -391,16 +394,42 @@ export class WebSocketSessionBridge {
"broadcastAgentAttention"
);
const allClientsStale = allStates.every((state) => state.isStale);
this.logger.debug({ allClientsStale }, "Client staleness check");
if (allClientsStale) {
const hasActiveWebClient = allStates.some(
(state) => state.deviceType === "web" && !state.isStale
);
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();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
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, {
title: "Agent needs attention",
body: `Reason: ${params.reason}`,
data: { agentId: params.agentId },
title,
body,
data: { agentId: params.agentId, reason: params.reason },
});
}
}