Move per-session permission auto-accept policy ownership from the UI to the OpenChamber server so enabled sessions continue running when clients disconnect or the server restarts. - persist explicit per-session policies in OpenChamber settings - inherit the nearest explicit policy across subagent session hierarchies - allow child sessions to opt out of an inherited parent policy - immediately accept matching global and directory-scoped pending requests - process future requests without requiring a connected UI client - reconcile pending permissions after startup and event-stream reconnects - deduplicate concurrent requests and retry transient reply failures - synchronize policy updates across connected clients - migrate existing browser-persisted policies to server storage - suppress auto-accepted permission cards before they enter UI state - show deduplicated permission toasts for inactive sessions - preserve foreground-only permission handling in VS Code - integrate directory-aware notification routing from main - add coverage for persistence, inheritance, retries, reconciliation, pending requests, client hydration, and inactive-session toasts
756 lines
29 KiB
JavaScript
756 lines
29 KiB
JavaScript
export const createNotificationTriggerRuntime = (deps) => {
|
|
const {
|
|
readSettingsFromDisk,
|
|
prepareNotificationLastMessage,
|
|
buildTemplateVariables,
|
|
extractLastMessageText,
|
|
fetchLastAssistantMessageText,
|
|
resolveNotificationTemplate,
|
|
shouldApplyResolvedTemplateMessage,
|
|
emitDesktopNotification,
|
|
broadcastUiNotification,
|
|
sendPushToAllUiSessions,
|
|
sendApnsToAllUiSessions,
|
|
isAnyInteractiveClientVisible,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
} = deps;
|
|
let getIsSessionAutoAccepting = deps.getIsSessionAutoAccepting;
|
|
const setGetIsSessionAutoAccepting = (resolver) => {
|
|
getIsSessionAutoAccepting = typeof resolver === 'function' ? resolver : undefined;
|
|
};
|
|
|
|
// App-icon badge for native push: the set of DISTINCT collapse-ids (the push
|
|
// `tag`, e.g. `ready-<sessionId>` / `permission-<requestKey>`) we've sent since
|
|
// the app was last foregrounded. The badge is the absolute APNs `aps.badge`.
|
|
//
|
|
// We key by `tag`, not sessionId, because the tag IS the banner identity: iOS
|
|
// uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while
|
|
// different tags are distinct banners. One session can raise several banners
|
|
// (ready + question + permission are different tags), so counting sessionIds
|
|
// both over- and under-counts the lock-screen stack; counting tags mirrors it.
|
|
//
|
|
// We deliberately do NOT derive this from the live attention snapshot
|
|
// (needsAttention/isViewed): that machinery is for in-app indicators on
|
|
// connected clients — a backgrounded client stays "viewing", and needsAttention
|
|
// is set by a separate session.status event that races the push trigger. The
|
|
// set is cleared when a UI client reports visible (`clearPendingPushBadge`),
|
|
// the same moment the device zeroes its icon badge on becomeActive.
|
|
const pendingPushTags = new Set();
|
|
const clearPendingPushBadge = () => {
|
|
pendingPushTags.clear();
|
|
};
|
|
const trackPushAndCountBadge = (tag) => {
|
|
if (typeof tag === 'string' && tag.length > 0) {
|
|
pendingPushTags.add(tag);
|
|
}
|
|
return pendingPushTags.size;
|
|
};
|
|
|
|
// Generic notification for native push (per the mobile design): a fixed, scenario-based
|
|
// title + the session name as the body. No model/project/message content crosses the relay.
|
|
const APNS_TITLE_BY_TYPE = {
|
|
ready: 'Agent response is ready',
|
|
error: 'Agent hit an error',
|
|
question: 'Agent needs your input',
|
|
permission: 'Agent needs permission',
|
|
goal_complete: 'Goal complete',
|
|
goal_blocked: 'Goal blocked',
|
|
goal_budget: 'Goal reached its token budget',
|
|
};
|
|
|
|
const toApnsGenericPayload = (payload) => {
|
|
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
|
|
const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
|
|
? data.sessionName.trim()
|
|
: 'Session';
|
|
return {
|
|
title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update',
|
|
body: sessionName,
|
|
badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
|
|
tag: payload?.tag,
|
|
// sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
|
|
data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined,
|
|
};
|
|
};
|
|
|
|
// Fan a notification out to every delivery channel: browser web-push (full templated
|
|
// payload) and native iOS APNs (generic model-based text). Both share the dedup tag and
|
|
// `requireNoSse` focus gate; a failure in one channel must not block the other.
|
|
const fanoutPush = (payload, options) => {
|
|
// Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is
|
|
// currently visible, it already shows the in-app notification, so skip the native push to the
|
|
// phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we
|
|
// also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push.
|
|
const interactiveVisible = isAnyInteractiveClientVisible?.() === true;
|
|
return Promise.all([
|
|
Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => {
|
|
console.warn('[Push] web-push fanout failed:', error?.message ?? error);
|
|
}),
|
|
interactiveVisible
|
|
? Promise.resolve()
|
|
: Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => {
|
|
console.warn('[APNs] fanout failed:', error?.message ?? error);
|
|
}),
|
|
]);
|
|
};
|
|
|
|
let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function'
|
|
? deps.getIsWindowFocused
|
|
: null;
|
|
|
|
const setGetIsWindowFocused = (cb) => {
|
|
getIsWindowFocused = typeof cb === 'function' ? cb : null;
|
|
};
|
|
|
|
const PUSH_READY_COOLDOWN_MS = 5000;
|
|
const PUSH_QUESTION_DEBOUNCE_MS = 500;
|
|
const PUSH_PERMISSION_DEBOUNCE_MS = 500;
|
|
const pushQuestionDebounceTimers = new Map();
|
|
const pushPermissionDebounceTimers = new Map();
|
|
const notifiedPermissionRequests = new Set();
|
|
const lastReadyNotificationAt = new Map();
|
|
const lastErrorNotificationAt = new Map();
|
|
|
|
const sessionParentIdCache = new Map();
|
|
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
|
|
|
|
// Sessions where the client has enabled Permission Auto-Accept. Mirrored
|
|
// from the client-side permissionStore via POST /api/notifications/auto-accept
|
|
// so the server can suppress permission notifications BEFORE dispatch (the
|
|
// 500ms debounce race otherwise leaks notifications for auto-accepted
|
|
// permissions when the replied round-trip is slower than the debounce).
|
|
const autoAcceptingSessions = new Set();
|
|
const setAutoAcceptSession = (sessionId, enabled) => {
|
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
if (enabled) {
|
|
autoAcceptingSessions.add(sessionId);
|
|
} else {
|
|
autoAcceptingSessions.delete(sessionId);
|
|
}
|
|
};
|
|
|
|
const buildSessionDeepLinkUrl = (sessionId) => {
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return '/';
|
|
}
|
|
return `/?session=${encodeURIComponent(sessionId)}`;
|
|
};
|
|
|
|
const getSessionParentCacheKey = (sessionId, directory) => `${directory || ''}\0${sessionId}`;
|
|
|
|
const getCachedSessionParentId = (sessionId, directory) => {
|
|
const cacheKey = getSessionParentCacheKey(sessionId, directory);
|
|
const entry = sessionParentIdCache.get(cacheKey);
|
|
if (!entry) return undefined;
|
|
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
|
|
sessionParentIdCache.delete(cacheKey);
|
|
return undefined;
|
|
}
|
|
return entry.parentID;
|
|
};
|
|
|
|
const setCachedSessionParentId = (sessionId, directory, parentID) => {
|
|
sessionParentIdCache.set(getSessionParentCacheKey(sessionId, directory), { parentID: parentID ?? null, at: Date.now() });
|
|
};
|
|
|
|
const getParentIdFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return undefined;
|
|
if (payload.type !== 'session.created' && payload.type !== 'session.updated') return undefined;
|
|
const parentID = payload.properties?.info?.parentID ?? null;
|
|
return typeof parentID === 'string' && parentID.length > 0 ? parentID : null;
|
|
};
|
|
|
|
const maybeCacheSessionParentFromPayload = (payload) => {
|
|
const sessionId = extractSessionIdFromPayload(payload);
|
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
const directory = extractDirectoryFromPayload(payload);
|
|
const parentID = getParentIdFromPayload(payload);
|
|
if (parentID === undefined) return;
|
|
setCachedSessionParentId(sessionId, directory, parentID);
|
|
};
|
|
|
|
const fetchSessionParentId = async (sessionId, directory) => {
|
|
if (!sessionId) return undefined;
|
|
|
|
const cached = getCachedSessionParentId(sessionId, directory);
|
|
if (cached !== undefined) return cached;
|
|
|
|
try {
|
|
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
|
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...getOpenCodeAuthHeaders(),
|
|
},
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (!response.ok) {
|
|
return undefined;
|
|
}
|
|
const session = await response.json().catch(() => null);
|
|
if (!session || typeof session !== 'object') {
|
|
return undefined;
|
|
}
|
|
|
|
const parentID = typeof session.parentID === 'string' && session.parentID.length > 0
|
|
? session.parentID
|
|
: null;
|
|
setCachedSessionParentId(sessionId, directory, parentID);
|
|
return parentID;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
// Mirrors client-side autoRespondsPermission: a session auto-accepts if it
|
|
// OR any ancestor is flagged. Walks the parent chain via fetchSessionParentId.
|
|
const isSessionAutoAccepting = async (sessionId, directory) => {
|
|
if (!sessionId || autoAcceptingSessions.size === 0) return false;
|
|
let current = sessionId;
|
|
const seen = new Set();
|
|
while (current && !seen.has(current)) {
|
|
if (autoAcceptingSessions.has(current)) return true;
|
|
seen.add(current);
|
|
const parent = await fetchSessionParentId(current, directory);
|
|
if (!parent) return false;
|
|
current = parent;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const extractSessionIdFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
const props = payload.properties;
|
|
const info = props?.info;
|
|
const sessionId =
|
|
info?.sessionID ??
|
|
info?.sessionId ??
|
|
props?.sessionID ??
|
|
props?.sessionId ??
|
|
props?.session ??
|
|
null;
|
|
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
};
|
|
|
|
const extractDirectoryFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return undefined;
|
|
const props = payload.properties;
|
|
const directory = props?.directory ?? props?.info?.directory;
|
|
if (typeof directory !== 'string') return undefined;
|
|
const trimmed = directory.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
};
|
|
|
|
const formatMode = (raw) => {
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
const normalized = value.length > 0 ? value : 'agent';
|
|
return normalized
|
|
.split(/[-_\s]+/)
|
|
.filter(Boolean)
|
|
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
|
.join(' ');
|
|
};
|
|
|
|
const formatModelId = (raw) => {
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
if (!value) {
|
|
return 'Assistant';
|
|
}
|
|
|
|
const tokens = value.split(/[-_]+/).filter(Boolean);
|
|
const result = [];
|
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
const current = tokens[i];
|
|
const next = tokens[i + 1];
|
|
if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
|
|
result.push(`${current}.${next}`);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
result.push(current);
|
|
}
|
|
|
|
return result
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ');
|
|
};
|
|
|
|
// A session with an ACTIVE goal suppresses per-turn ready notifications;
|
|
// the session-goal runtime sends its own notification when the goal
|
|
// settles. Fetch failures fall through to normal notification behavior.
|
|
const hasActiveSessionGoal = async (sessionId, directory) => {
|
|
if (!sessionId) return false;
|
|
try {
|
|
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
|
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (!response.ok) return false;
|
|
const session = await response.json().catch(() => null);
|
|
const goal = session?.metadata?.openchamber?.goal;
|
|
return Boolean(goal && typeof goal === 'object' && goal.status === 'active');
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const maybeSendPushForTrigger = async (payload) => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return;
|
|
}
|
|
|
|
maybeCacheSessionParentFromPayload(payload);
|
|
|
|
const sessionId = extractSessionIdFromPayload(payload);
|
|
const notificationDirectory = extractDirectoryFromPayload(payload);
|
|
if ((payload.type === 'session.idle' || payload.type === 'session.error') && sessionId) {
|
|
const error = payload.properties?.error;
|
|
const errorText = typeof error?.message === 'string'
|
|
? error.message
|
|
: typeof error === 'string' ? error : '';
|
|
await maybeSendPushForTrigger({
|
|
...payload,
|
|
type: 'message.updated',
|
|
properties: {
|
|
...payload.properties,
|
|
info: {
|
|
sessionID: sessionId,
|
|
role: 'assistant',
|
|
finish: payload.type === 'session.error' ? 'error' : 'stop',
|
|
...(errorText ? { parts: [{ type: 'text', text: errorText }] } : {}),
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'message.updated') {
|
|
const info = payload.properties?.info;
|
|
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
|
const settings = await readSettingsFromDisk();
|
|
|
|
if (settings.notifyOnSubtasks === false) {
|
|
const parentIDFromPayload = getParentIdFromPayload(payload);
|
|
const parentID = parentIDFromPayload
|
|
? parentIDFromPayload
|
|
: await fetchSessionParentId(sessionId, notificationDirectory);
|
|
|
|
if (parentID !== null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (settings.notifyOnCompletion === false) {
|
|
return;
|
|
}
|
|
|
|
// While a goal drives the session, per-turn "ready" notifications are
|
|
// noise produced by the goal loop itself — the goal's own settle
|
|
// notification (complete/blocked/budget) is the final word instead.
|
|
if (await hasActiveSessionGoal(sessionId, notificationDirectory)) {
|
|
return;
|
|
}
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
|
|
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
|
|
return;
|
|
}
|
|
lastReadyNotificationAt.set(sessionId, now);
|
|
|
|
let title = `${formatMode(info?.mode)} agent is ready`;
|
|
let body = `${formatModelId(info?.modelID)} completed the task`;
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const templates = settings.notificationTemplates || {};
|
|
const isSubtask = await fetchSessionParentId(sessionId, notificationDirectory);
|
|
const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
|
|
? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
|
|
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
|
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
|
|
const messageId = info?.id;
|
|
let lastMessage = extractLastMessageText(payload);
|
|
if (!lastMessage) {
|
|
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
|
|
}
|
|
|
|
variables.last_message = await prepareNotificationLastMessage({
|
|
message: lastMessage,
|
|
settings,
|
|
});
|
|
|
|
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
title,
|
|
body,
|
|
tag: `ready-${sessionId}`,
|
|
kind: 'ready',
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
await fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `ready-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'ready',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}
|
|
|
|
if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) {
|
|
const settings = await readSettingsFromDisk();
|
|
if (settings.notifyOnError === false) return;
|
|
|
|
const now = Date.now();
|
|
const lastAt = lastErrorNotificationAt.get(sessionId) ?? 0;
|
|
if (now - lastAt < PUSH_READY_COOLDOWN_MS) return;
|
|
lastErrorNotificationAt.set(sessionId, now);
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
let title = 'Tool error';
|
|
let body = 'An error occurred';
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
const errorMessageId = info?.id;
|
|
let lastMessage = extractLastMessageText(payload);
|
|
if (!lastMessage) {
|
|
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
|
|
}
|
|
|
|
variables.last_message = await prepareNotificationLastMessage({
|
|
message: lastMessage,
|
|
settings,
|
|
});
|
|
|
|
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
|
|
const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Error template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
title,
|
|
body,
|
|
tag: `error-${sessionId}`,
|
|
kind: 'error',
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
await fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `error-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'error',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'question.asked' && sessionId) {
|
|
const existingTimer = pushQuestionDebounceTimers.get(sessionId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
pushQuestionDebounceTimers.delete(sessionId);
|
|
|
|
const settings = await readSettingsFromDisk();
|
|
if (settings.notifyOnQuestion === false) {
|
|
return;
|
|
}
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
const firstQuestion = payload.properties?.questions?.[0];
|
|
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
|
|
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
|
|
|
|
let title = /plan\s*mode/i.test(header)
|
|
? 'Switch to plan mode'
|
|
: /build\s*agent/i.test(header)
|
|
? 'Switch to build mode'
|
|
: header || 'Input needed';
|
|
let body = questionText || 'Agent is waiting for your response';
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
variables.last_message = questionText || header || '';
|
|
|
|
const templates = settings.notificationTemplates || {};
|
|
const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
|
|
|
|
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
kind: 'question',
|
|
title,
|
|
body,
|
|
tag: `question-${sessionId}`,
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
void fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `question-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'question',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}, PUSH_QUESTION_DEBOUNCE_MS);
|
|
|
|
pushQuestionDebounceTimers.set(sessionId, timer);
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'permission.replied' && sessionId) {
|
|
const requestId = payload.properties?.requestID ?? payload.properties?.requestId ?? payload.properties?.id;
|
|
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
|
|
const pendingNotification = pushPermissionDebounceTimers.get(sessionId);
|
|
if (!pendingNotification) {
|
|
return;
|
|
}
|
|
|
|
// Some runtimes may omit requestID on permission.replied.
|
|
// When request ID is missing, clear session debounce to avoid
|
|
// showing stale permission notifications for auto-approved prompts.
|
|
if (!requestKey || !pendingNotification.requestKey || pendingNotification.requestKey === requestKey) {
|
|
clearTimeout(pendingNotification.timer);
|
|
pushPermissionDebounceTimers.delete(sessionId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'permission.asked' && sessionId) {
|
|
const requestId = payload.properties?.id ?? payload.properties?.requestID ?? payload.properties?.requestId;
|
|
const permission = payload.properties?.permission;
|
|
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
|
|
if (requestKey && notifiedPermissionRequests.has(requestKey)) {
|
|
return;
|
|
}
|
|
|
|
// Client may be in Permission Auto-Accept for this session (or any
|
|
// ancestor). Skip the whole notification path — the client responds
|
|
// directly and the user has opted out of approval prompts.
|
|
if (await (getIsSessionAutoAccepting?.(sessionId, notificationDirectory)
|
|
?? isSessionAutoAccepting(sessionId, notificationDirectory))) {
|
|
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
|
return;
|
|
}
|
|
|
|
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer.timer);
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
pushPermissionDebounceTimers.delete(sessionId);
|
|
|
|
if (await (getIsSessionAutoAccepting?.(sessionId, notificationDirectory)
|
|
?? isSessionAutoAccepting(sessionId, notificationDirectory))) {
|
|
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
|
return;
|
|
}
|
|
|
|
const settings = await readSettingsFromDisk();
|
|
|
|
if (settings.notifyOnQuestion === false) {
|
|
return;
|
|
}
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
const sessionTitle = payload.properties?.sessionTitle;
|
|
const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : '';
|
|
const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
|
|
? sessionTitle.trim()
|
|
: permissionText || 'Agent is waiting for your approval';
|
|
|
|
let title = 'Permission required';
|
|
let body = fallbackMessage;
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
variables.last_message = fallbackMessage;
|
|
|
|
const templates = settings.notificationTemplates || {};
|
|
const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
|
|
|
|
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
kind: 'permission',
|
|
title,
|
|
body,
|
|
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
if (requestKey) {
|
|
notifiedPermissionRequests.add(requestKey);
|
|
}
|
|
|
|
void fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `permission-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'permission',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}, PUSH_PERMISSION_DEBOUNCE_MS);
|
|
|
|
pushPermissionDebounceTimers.set(sessionId, { timer, requestKey });
|
|
}
|
|
};
|
|
|
|
// Goal settle push: same fanout as the trigger paths (web-push with the
|
|
// full text; APNs with the generic per-type title and the session name as
|
|
// body, so the relay never sees content).
|
|
const sendGoalSettlePush = async ({ sessionId, directory, status, title, body }) => {
|
|
let sessionName = '';
|
|
try {
|
|
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
|
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (response.ok) {
|
|
const session = await response.json().catch(() => null);
|
|
if (typeof session?.title === 'string') sessionName = session.title.trim();
|
|
}
|
|
} catch {
|
|
// Session name is presentation sugar for the mobile push — never block on it.
|
|
}
|
|
const type = status === 'complete' ? 'goal_complete' : (status === 'budgetLimited' ? 'goal_budget' : 'goal_blocked');
|
|
await fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `goal-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type,
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
};
|
|
|
|
return {
|
|
maybeSendPushForTrigger,
|
|
setAutoAcceptSession,
|
|
setGetIsWindowFocused,
|
|
setGetIsSessionAutoAccepting,
|
|
clearPendingPushBadge,
|
|
sendGoalSettlePush,
|
|
};
|
|
};
|