Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision ef519a306e73601f098daf2a3cef9cdaf10a4d20)
+++ src/routes/activitypub.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
@@ -174,4 +174,24 @@
   <p class="small">No Shaer? Any fediverse app can follow ${esc(handle)}.</p>
 </main></body></html>`);
+});
+
+// ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
+// Hold the request until something push-worthy lands for this account, then
+// answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
+// The thread in the app stays live without interval polling.
+router.get('/ap/users/:slug/inbox/wait', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  let settled = false;
+  const done = (code) => {
+    if (settled) return;
+    settled = true;
+    clearTimeout(timer);
+    off();
+    if (!res.headersSent) res.status(code).end();
+  };
+  const off = AP.onNews(auth.site.slug, () => done(200));
+  const timer = setTimeout(() => done(204), 25_000);
+  req.on('close', () => done(204));
 });
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision ef519a306e73601f098daf2a3cef9cdaf10a4d20)
+++ src/services/ActivityPubService.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
@@ -1363,4 +1363,26 @@
 function pushEvent(slug, event) {
   try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
+  wakeNews(slug);   // long-poll waiters (Robins verzoek, 31-7): same moments as push
+}
+
+// ── Long-poll on news (Robins verzoek, 31-7) ─────────────────────
+// The app holds GET /ap/users/:slug/inbox/wait open; the moment anything
+// push-worthy lands for that account (a message, a reply, a wave, a help
+// request) every waiter is woken and the app re-reads its feed. In-process
+// on purpose: one Klonkt is one process, and a waiter is one callback.
+const _newsWaiters = new Map();   // slug -> Set<cb>
+export function onNews(slug, cb) {
+  let set = _newsWaiters.get(slug);
+  if (!set) { set = new Set(); _newsWaiters.set(slug, set); }
+  set.add(cb);
+  return () => { set.delete(cb); if (!set.size) _newsWaiters.delete(slug); };
+}
+export function wakeNews(slug) {
+  const set = _newsWaiters.get(slug);
+  if (!set || !set.size) return;
+  const cbs = [...set];
+  set.clear();
+  _newsWaiters.delete(slug);
+  for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } }
 }
 // Hub-aware path prefix for a site's pages ('' in solo).
@@ -4250,4 +4272,4 @@
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
-  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor, getReplyMessages,
+  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor, getReplyMessages, onNews, wakeNews,
 };
Index: test/long-poll.test.js
===================================================================
--- test/long-poll.test.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ test/long-poll.test.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
@@ -0,0 +1,32 @@
+// Long-poll on news (Robins verzoek, 31-7): the thread in the app holds a
+// request open; anything push-worthy wakes every waiter exactly once, and an
+// unsubscribed waiter is never woken. The route wires this to 200/204.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+test('news wakes every waiter once, and only for that account', () => {
+  let a = 0, b = 0, other = 0;
+  AP.onNews('kid', () => a++);
+  AP.onNews('kid', () => b++);
+  AP.onNews('ander', () => other++);
+  AP.wakeNews('kid');
+  assert.equal(a, 1); assert.equal(b, 1);
+  assert.equal(other, 0, 'someone else\'s news is not yours');
+  AP.wakeNews('kid');
+  assert.equal(a, 1, 'a waiter fires once; the app re-arms with a new request');
+});
+
+test('an unsubscribed waiter stays silent', () => {
+  let fired = 0;
+  const off = AP.onNews('kid', () => fired++);
+  off();
+  AP.wakeNews('kid');
+  assert.equal(fired, 0);
+});
