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') ||