feat: add presence-gated Expo push notifications
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user