diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index 0411fdbf..41ac42da 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -786,6 +786,11 @@ export const NotificationSettings: React.FC = () => { /> Summarize Last Message +
+ + Requires {'{last_message}'} in the notification template. + +
diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 0e4ecd98..f22cba68 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -169,6 +169,15 @@ export const usePermissionStore = create()( return { autoAccept }; }); + // Mirror state to the server so it can suppress permission + // notifications at the source (otherwise the 500ms debounce + // races with the client's auto-response and can leak). + void fetch('/api/notifications/auto-accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, enabled }), + }).catch(() => { /* best-effort */ }); + if (!enabled) { return; } @@ -260,6 +269,21 @@ export const usePermissionStore = create()( autoAccept: nextAutoAccept, }; }, + onRehydrateStorage: () => (state) => { + if (!state) return; + // Re-broadcast auto-accept state to the server after + // rehydration so server-side notification suppression + // survives page reloads / server restarts. + for (const [sid, enabled] of Object.entries(state.autoAccept || {})) { + if (enabled === true) { + void fetch('/api/notifications/auto-accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: sid, enabled: true }), + }).catch(() => { /* best-effort */ }); + } + } + }, } ), { name: "permission-store" } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 7a403232..0751983b 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -646,6 +646,7 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({ }); const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); +const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args); const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), @@ -1098,6 +1099,7 @@ async function main(options = {}) { modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL, fetchFreeZenModels, getCachedZenModels, + setAutoAcceptSession, }); uiAuthController = bootstrapResult.uiAuthController; diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js index ceb4b06f..d4c7b2c3 100644 --- a/packages/web/server/lib/notifications/routes.js +++ b/packages/web/server/lib/notifications/routes.js @@ -46,6 +46,7 @@ export const registerNotificationRoutes = (app, dependencies) => { markSessionUnviewed, markUserMessageSent, setPushInitialized, + setAutoAcceptSession, } = dependencies; const ensureSessionWatcher = async () => { @@ -294,4 +295,20 @@ export const registerNotificationRoutes = (app, dependencies) => { messageSent: true, }); }); + + // Mirror client-side Permission Auto-Accept state to the server so it can + // suppress permission notifications at the source (the 500ms debounce race + // otherwise leaks notifications for auto-accepted permissions). + app.post('/api/notifications/auto-accept', (req, res) => { + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''; + const enabled = body.enabled === true; + if (!sessionId) { + return res.status(400).json({ error: 'sessionId required' }); + } + if (typeof setAutoAcceptSession === 'function') { + setAutoAcceptSession(sessionId, enabled); + } + return res.json({ success: true, sessionId, enabled }); + }); }; diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js index 91a4cee7..70bfbca1 100644 --- a/packages/web/server/lib/notifications/runtime.js +++ b/packages/web/server/lib/notifications/runtime.js @@ -27,6 +27,21 @@ export const createNotificationTriggerRuntime = (deps) => { 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 '/'; @@ -80,6 +95,22 @@ export const createNotificationTriggerRuntime = (deps) => { } }; + // 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) => { + 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); + if (!parent) return false; + current = parent; + } + return false; + }; + const extractSessionIdFromPayload = (payload) => { if (!payload || typeof payload !== 'object') return null; const props = payload.properties; @@ -390,6 +421,14 @@ export const createNotificationTriggerRuntime = (deps) => { 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 isSessionAutoAccepting(sessionId)) { + if (requestKey) notifiedPermissionRequests.add(requestKey); + return; + } + const existingTimer = pushPermissionDebounceTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer.timer); @@ -473,5 +512,6 @@ export const createNotificationTriggerRuntime = (deps) => { return { maybeSendPushForTrigger, + setAutoAcceptSession, }; }; diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 8d4792c6..b1254c17 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -47,6 +47,7 @@ export const createBootstrapRuntime = (dependencies) => { modelsMetadataCacheTtl, fetchFreeZenModels, getCachedZenModels, + setAutoAcceptSession, } = options; registerServerStatusRoutes(app, { @@ -100,6 +101,7 @@ export const createBootstrapRuntime = (dependencies) => { markSessionUnviewed: sessionRuntime.markSessionUnviewed, markUserMessageSent: sessionRuntime.markUserMessageSent, setPushInitialized, + setAutoAcceptSession, }); registerOpenChamberRoutes(app, { diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index a29fc56d..41865b44 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -260,6 +260,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/terminal') || req.path.startsWith('/api/opencode') || req.path.startsWith('/api/push') || + req.path.startsWith('/api/notifications') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || req.path.startsWith('/api/tts') ||