Prechádzať zdrojové kódy

实现 8800 到 iOS 的 APNs 推送

LSamien 2 mesiacov pred
rodič
commit
956bd401f4

+ 5 - 0
.env.example

@@ -11,6 +11,11 @@ HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 # HERMES_YAOYAO_TLS_CERT=/absolute/path/to/cert.pem
 # HERMES_YAOYAO_TLS_KEY=/absolute/path/to/key.pem
 # HERMES_YAOYAO_HOME=/Users/you/.hermes-yaoyao
+# iOS 后台通知使用 APNs Token Key。四项中前三项必须同时配置,.p8 不得放入仓库。
+# HERMES_YAOYAO_APNS_KEY_FILE=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
+# HERMES_YAOYAO_APNS_KEY_ID=XXXXXXXXXX
+# HERMES_YAOYAO_APNS_TEAM_ID=GMU6W5FKQ6
+# HERMES_YAOYAO_APNS_TOPIC=cn.samien.yaoyao.hermes
 # 固定 Git 发布源。系统更新只接受这里发现的 vX.Y.Z 标签,不接受浏览器指定仓库。
 # HERMES_YAOYAO_RELEASE_SOURCE=https://git.samien.cn/samien/hermes-yaoyao.git
 # HERMES_YAOYAO_RELEASE_ROOT=/Users/you/.local/share/hermes-yaoyao

+ 22 - 0
README.md

@@ -19,6 +19,28 @@ iOS 默认连接 `http://主机:8800` 并使用 8800 用户登录;已有 9119
 保存;外部或 Docker 9119 可在“系统管理”中配置,或设置
 `HERMES_YAOYAO_UPSTREAM_USERNAME` 和 `HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE`。
 
+### iOS 后台消息推送
+
+iOS 在线时继续使用 8800 的 WebSocket 实时接收消息;App 被挂起或未运行时,8800
+可作为 APNs Provider 发送系统通知。推送设备与当前 8800 用户绑定,普通聊天只提醒
+该用户经 8800 发起的任务;团队在用户首次发言后自动订阅,也可在团队页关闭。
+
+在 Apple Developer 中为 `cn.samien.yaoyao.hermes` 开启 Push Notifications 并创建
+Token Key,把下载的 `.p8` 保存在仓库外、权限设为 `0600`,然后为 8800 配置:
+
+```bash
+export HERMES_YAOYAO_APNS_KEY_FILE=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
+export HERMES_YAOYAO_APNS_KEY_ID=XXXXXXXXXX
+export HERMES_YAOYAO_APNS_TEAM_ID=GMU6W5FKQ6
+export HERMES_YAOYAO_APNS_TOPIC=cn.samien.yaoyao.hermes
+```
+
+前三项必须同时存在;缺失或无效时聊天服务仍会启动,iOS 自动保留原有本地通知降级。
+管理员可在“系统管理 → iOS 消息推送”查看配置、注册设备和待发送数量,页面与日志均
+不会显示 `.p8` 内容或 device token。Docker 部署需要把 `.p8` 只读挂载到容器,并让
+`HERMES_YAOYAO_APNS_KEY_FILE` 指向容器内绝对路径。APNs 只要求 8800 能主动访问
+Apple,不需要把 8800 暴露到公网;手机访问 8800 仍应优先使用 HTTPS/WSS 或 Tailscale。
+
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 

+ 4 - 0
bin/hermes-yaoyao.mjs

@@ -48,6 +48,10 @@ function environment(env = process.env) {
     'HERMES_YAOYAO_RELEASE_SOURCE',
     'HERMES_YAOYAO_RELEASE_ROOT',
     'HERMES_YAOYAO_ALLOW_REMOTE_UPDATE',
+    'HERMES_YAOYAO_APNS_KEY_FILE',
+    'HERMES_YAOYAO_APNS_KEY_ID',
+    'HERMES_YAOYAO_APNS_TEAM_ID',
+    'HERMES_YAOYAO_APNS_TOPIC',
     'HERMES_YAOYAO_SERVICE_ROOT',
   ]
   return Object.fromEntries(allowed.flatMap(key => env[key] ? [[key, env[key]]] : []))

+ 4 - 0
compose.yaml

@@ -28,6 +28,10 @@ services:
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: "1"
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
       HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
+      HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
+      HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"
+      HERMES_YAOYAO_APNS_TEAM_ID: "${HERMES_YAOYAO_APNS_TEAM_ID:-}"
+      HERMES_YAOYAO_APNS_TOPIC: "${HERMES_YAOYAO_APNS_TOPIC:-}"
     volumes:
       - yaoyao-data:/var/lib/hermes-yaoyao
     tmpfs:

+ 67 - 0
src/client/api/push.ts

@@ -0,0 +1,67 @@
+import { apiRequest } from './client'
+import { bool, number, record, string, values } from '@/utils/normalize'
+
+export interface PushCapabilities {
+  protocolVersion: number
+  enabled: boolean
+  topic?: string
+  previewMode?: string
+  events: string[]
+  maxSummaryCharacters: number
+}
+
+export interface PushSystemStatus {
+  configured: boolean
+  healthy: boolean
+  topic?: string
+  registrationCount: number
+  pendingCount: number
+  lastSuccessAt?: number
+  lastError?: string
+}
+
+export async function getPushCapabilities(): Promise<PushCapabilities> {
+  const payload = record(await apiRequest<unknown>('/api/app/push/v1/capabilities'))
+  return {
+    protocolVersion: number(payload.protocolVersion ?? payload.protocol_version),
+    enabled: bool(payload.enabled),
+    topic: string(payload.topic) || undefined,
+    previewMode: string(payload.previewMode ?? payload.preview_mode) || undefined,
+    events: values(payload.events).map(String).filter(Boolean),
+    maxSummaryCharacters: number(
+      payload.maxSummaryCharacters ?? payload.maximumSummaryCharacters ?? payload.max_summary_characters,
+      180,
+    ),
+  }
+}
+
+export async function getGroupPushSubscriptions(): Promise<Set<string>> {
+  const payload = record(await apiRequest<unknown>('/api/app/push/v1/group-subscriptions'))
+  const roomIds = values(payload.roomIds ?? payload.room_ids ?? payload.subscriptions ?? payload.items)
+    .map(value => typeof value === 'string' ? value : string(record(value).roomId ?? record(value).room_id))
+    .filter(Boolean)
+  return new Set(roomIds)
+}
+
+export async function setGroupPushSubscription(roomId: string, enabled: boolean): Promise<boolean> {
+  const payload = record(await apiRequest<unknown>(`/api/app/push/v1/group-subscriptions/${encodeURIComponent(roomId)}`, {
+    method: 'PUT', body: { enabled },
+  }))
+  const subscription = record(payload.subscription)
+  return typeof payload.enabled === 'boolean'
+    ? payload.enabled
+    : typeof subscription.enabled === 'boolean' ? subscription.enabled : enabled
+}
+
+export async function getPushSystemStatus(): Promise<PushSystemStatus> {
+  const payload = record(await apiRequest<unknown>('/api/app/system/push-status'))
+  return {
+    configured: bool(payload.configured),
+    healthy: bool(payload.healthy),
+    topic: string(payload.topic) || undefined,
+    registrationCount: number(payload.registrationCount ?? payload.registration_count),
+    pendingCount: number(payload.pendingCount ?? payload.pending_count),
+    lastSuccessAt: number(payload.lastSuccessAt ?? payload.last_success_at) || undefined,
+    lastError: string(payload.lastError ?? payload.last_error) || undefined,
+  }
+}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 11 - 2
src/client/components/app/SystemManagementDialog.vue


+ 2 - 0
src/client/components/common/AppIcon.vue

@@ -45,6 +45,7 @@ type IconName =
   | 'pin-filled'
   | 'pin-off'
   | 'globe'
+  | 'bell'
 
 withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
 </script>
@@ -118,6 +119,7 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     <template v-else-if="name === 'pin-filled'"><path d="M8 3a1 1 0 0 0 0 2h1v4.72l-2.8 4.7A1 1 0 0 0 7.06 16H11v5a1 1 0 1 0 2 0v-5h3.94a1 1 0 0 0 .86-1.58L15 9.72V5h1a1 1 0 1 0 0-2z" fill="currentColor" stroke="none" /></template>
     <template v-else-if="name === 'pin-off'"><path d="M9 4v6l-2 4v2h10v-2l-2-4V4M12 16v5M8 4h8" /><path d="M4 4l16 16" /></template>
     <template v-else-if="name === 'globe'"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></template>
+    <template v-else-if="name === 'bell'"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4" /></template>
   </svg>
 </template>
 

+ 52 - 2
src/client/views/GroupsView.vue

@@ -27,6 +27,7 @@ import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
 import { getModels } from '@/api/profiles'
+import { getGroupPushSubscriptions, getPushCapabilities, setGroupPushSubscription } from '@/api/push'
 import { useAuthStore } from '@/stores/auth'
 import { useGroupsStore } from '@/stores/groups'
 
@@ -56,6 +57,11 @@ const topicListRoomId = ref<string | null>(null)
 const archivedOverlayOpen = ref(false)
 const archivedRoomList = ref<GroupRoomSummary[]>([])
 const archivedTopicList = ref<GroupTopicSummary[]>([])
+const pushProtocolSupported = ref(false)
+const pushConfigured = ref(false)
+const subscribedPushRooms = ref<Set<string>>(new Set())
+const pushSubscriptionBusy = ref(false)
+const pushSubscriptionError = ref('')
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 const roomSidebarItems = computed(() => activeRooms.value.map(room => {
@@ -152,6 +158,9 @@ const displayAgents = computed(() => groups.agents.map(agent => {
   return identity ? { ...agent, displayName: identity.name } : agent
 }))
 const messages = computed(() => groups.messages.map(message => groupMessageToUi(message, displayAgents.value)))
+const selectedRoomPushEnabled = computed(() => Boolean(
+  groups.selectedRoomId && subscribedPushRooms.value.has(groups.selectedRoomId),
+))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const agents = computed(() => displayAgents.value.map(agentToUi))
@@ -434,11 +443,50 @@ async function send(payload: ComposerSubmit) {
   const prefix = quoted.value ? `> ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n` : ''
   try {
     await groups.sendMessage(`${prefix}${payload.text}`, payload.mentionIds, payload.files)
+    if (pushProtocolSupported.value && groups.selectedRoomId) {
+      void loadPushSubscriptions()
+    }
     quoted.value = null
     composer.value?.clearAfterSend()
   } catch { /* store publishes the error */ }
 }
 
+async function loadPushSubscriptions() {
+  pushSubscriptionError.value = ''
+  try {
+    const capabilities = await getPushCapabilities()
+    pushProtocolSupported.value = capabilities.protocolVersion === 1
+    pushConfigured.value = capabilities.enabled
+    subscribedPushRooms.value = pushProtocolSupported.value
+      ? await getGroupPushSubscriptions()
+      : new Set()
+  } catch {
+    // Older 8800 installations do not expose push capability. Keep group chat
+    // fully functional and simply omit the subscription control.
+    pushProtocolSupported.value = false
+    pushConfigured.value = false
+    subscribedPushRooms.value = new Set()
+  }
+}
+
+async function toggleSelectedRoomPush() {
+  const roomId = groups.selectedRoomId
+  if (!roomId || !pushProtocolSupported.value || !pushConfigured.value || pushSubscriptionBusy.value) return
+  pushSubscriptionBusy.value = true
+  pushSubscriptionError.value = ''
+  try {
+    const enabled = await setGroupPushSubscription(roomId, !selectedRoomPushEnabled.value)
+    const next = new Set(subscribedPushRooms.value)
+    if (enabled) next.add(roomId)
+    else next.delete(roomId)
+    subscribedPushRooms.value = next
+  } catch (cause) {
+    pushSubscriptionError.value = cause instanceof Error ? cause.message : '无法更新团队推送设置'
+  } finally {
+    pushSubscriptionBusy.value = false
+  }
+}
+
 async function updateRoom(patch: { name?: string; instructions?: string; avatar?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
   if (!groups.selectedRoom) return
   await groups.updateRoom(groups.selectedRoom.id, {
@@ -605,6 +653,7 @@ onMounted(async () => {
   document.addEventListener('pointerdown', handleRoomActionPointer)
   document.addEventListener('keydown', handleRoomActionKey)
   restoreShowThinking()
+  void loadPushSubscriptions()
   try {
     await groups.start()
     const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
@@ -720,11 +769,12 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
           <div class="group-header-actions">
             <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由管理员 ${hostAgent.displayName || hostAgent.profile} 负责回应`">管理员 {{ hostAgent.displayName || hostAgent.profile }}</span>
             <div class="group-avatars" aria-label="团队成员"><AgentAvatar v-for="agent in agents.slice(0, 4)" :key="agent.id" :name="agent.name" :avatar="agentAvatars[agent.nodeId === 'local' ? agent.profile || '' : `node:${agent.nodeId}:${agent.profile}`] || ''" :size="24" :title="agent.isHost ? `${agent.name} · 管理员` : agent.name" /><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
+            <button v-if="pushProtocolSupported" class="icon-button group-push-button" :class="{ active: selectedRoomPushEnabled }" type="button" :title="!pushConfigured ? '服务器尚未配置 iOS 推送' : selectedRoomPushEnabled ? '关闭该团队的 iOS 推送' : '开启该团队的 iOS 推送'" :aria-label="selectedRoomPushEnabled ? '关闭团队推送' : '开启团队推送'" :disabled="!groups.selectedRoom || !pushConfigured || pushSubscriptionBusy" @click="toggleSelectedRoomPush"><AppIcon name="bell" /></button>
             <button class="icon-button" type="button" title="管理团队" aria-label="管理团队" :disabled="!groups.selectedRoom" @click="openSelectedRoomManager"><AppIcon name="dots" /></button>
           </div>
         </template>
       </MessageTimeline>
-      <p v-if="groups.error" class="group-error" role="alert"><AppIcon name="alert" :size="13" />{{ groups.error }}</p>
+      <p v-if="groups.error || pushSubscriptionError" class="group-error" role="alert"><AppIcon name="alert" :size="13" />{{ groups.error || pushSubscriptionError }}</p>
       <ComposerShell
         ref="composer"
         mode="group"
@@ -836,7 +886,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 .group-topic-focus-sidebar :deep([data-sidebar-id="group-list"]) { position: sticky; z-index: 3; top: 0; min-height: 58px; padding-inline: 17px; border-bottom: 1px solid var(--line); border-radius: 0; background: var(--surface); }
 .group-topic-focus-sidebar :deep([data-sidebar-id="group-list"]:hover), .group-topic-focus-sidebar :deep([data-sidebar-id="group-list"]:focus-visible) { background: var(--surface-hover); }
 .archived-overlay-backdrop { position: fixed; z-index: 220; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.36); }.archived-overlay { width: min(460px, 100%); max-height: min(620px, calc(100vh - 40px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); box-shadow: 0 24px 70px rgba(0,0,0,.28); }.archived-overlay header { display: flex; align-items: center; justify-content: space-between; padding: 18px 18px 14px; border-bottom: 1px solid var(--line); }.archived-overlay header small { display: block; color: var(--text-secondary); font-size: 10px; }.archived-overlay header strong { font-size: 15px; }.archived-overlay header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; }.archived-section { display: grid; gap: 8px; padding: 16px 18px; }.archived-section + .archived-section { border-top: 1px solid var(--line); }.archived-section h3 { margin: 0; font-size: 12px; }.archived-section p { margin: 0; color: var(--text-secondary); font-size: 12px; }.archived-section article { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; }.archived-section article span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.archived-section article button { border: 0; border-radius: 7px; background: var(--surface-hover); color: var(--text-primary); cursor: pointer; padding: 5px 9px; }
-.group-header-actions { display: flex; align-items: center; gap: 8px; }.group-host-chip { max-width: 150px; overflow: hidden; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font-size: 9px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span { background: transparent; color: var(--text-secondary); }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
+.group-header-actions { display: flex; align-items: center; gap: 8px; }.group-host-chip { max-width: 150px; overflow: hidden; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font-size: 9px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.group-push-button.active { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span { background: transparent; color: var(--text-secondary); }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
 .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
 @media (max-width: 480px) { .group-avatars, .group-host-chip { display: none; } }
 @media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .group-menu-enter-active, .group-menu-leave-active { transition: none; } }

+ 291 - 0
src/server/apns.ts

@@ -0,0 +1,291 @@
+import {
+  connect,
+  constants as http2Constants,
+  type ClientHttp2Session,
+  type IncomingHttpHeaders,
+  type OutgoingHttpHeaders,
+} from 'node:http2'
+import {
+  createPrivateKey,
+  randomUUID,
+  sign,
+  type KeyObject,
+} from 'node:crypto'
+import { readFileSync } from 'node:fs'
+import type { APNsProviderConfig } from './config.js'
+
+export type APNsEnvironment = 'development' | 'production'
+
+export interface APNsRequest {
+  deviceToken: string
+  environment: APNsEnvironment
+  payload: Record<string, unknown>
+  apnsId?: string
+  collapseId?: string
+  expiration?: number
+  priority?: 5 | 10
+  pushType?: 'alert' | 'background'
+}
+
+export interface APNsTransportResponse {
+  status: number
+  headers?: IncomingHttpHeaders | Record<string, string | string[] | undefined>
+  body?: string
+}
+
+export interface APNsTransport {
+  send(endpoint: URL, headers: OutgoingHttpHeaders, body: Uint8Array): Promise<APNsTransportResponse>
+  close?(): void
+}
+
+export type APNsDisposition = 'success' | 'retry' | 'unregister' | 'configuration' | 'failed'
+
+export interface APNsSendResult {
+  disposition: APNsDisposition
+  status: number
+  reason?: string
+  apnsId?: string
+  timestamp?: number
+  retryAfterMs?: number
+}
+
+export interface APNsProviderOptions {
+  transport?: APNsTransport
+  now?: () => number
+}
+
+export const APNS_DEVELOPMENT_ENDPOINT = new URL('https://api.sandbox.push.apple.com')
+export const APNS_PRODUCTION_ENDPOINT = new URL('https://api.push.apple.com')
+export const APNS_MAX_PAYLOAD_BYTES = 4_096
+
+const TOKEN_RE = /^(?:[a-fA-F0-9]{2}){16,256}$/
+const PROVIDER_TOKEN_LIFETIME_MS = 50 * 60 * 1_000
+const TOKEN_CONFIGURATION_REASONS = new Set([
+  'ExpiredProviderToken',
+  'InvalidProviderToken',
+  'MissingProviderToken',
+  'BadTopic',
+  'TopicDisallowed',
+])
+const UNREGISTER_REASONS = new Set([
+  'BadDeviceToken',
+  'DeviceTokenNotForTopic',
+  'Unregistered',
+])
+
+function encodedJSON(value: unknown): string {
+  return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
+}
+
+export function createAPNsProviderToken(
+  config: Pick<APNsProviderConfig, 'keyFile' | 'keyId' | 'teamId'>,
+  now = Date.now(),
+  suppliedKey?: KeyObject,
+): string {
+  const key = suppliedKey ?? createPrivateKey(readFileSync(config.keyFile))
+  const header = encodedJSON({ alg: 'ES256', kid: config.keyId })
+  const claims = encodedJSON({ iss: config.teamId, iat: Math.floor(now / 1_000) })
+  const signingInput = `${header}.${claims}`
+  const signature = sign('sha256', Buffer.from(signingInput, 'ascii'), {
+    key,
+    dsaEncoding: 'ieee-p1363',
+  }).toString('base64url')
+  return `${signingInput}.${signature}`
+}
+
+function firstHeader(
+  headers: APNsTransportResponse['headers'],
+  name: string,
+): string | undefined {
+  const value = headers?.[name]
+  if (Array.isArray(value)) return value[0]
+  if (typeof value === 'string' || typeof value === 'number') return String(value)
+  return undefined
+}
+
+function responseReason(body: string | undefined): { reason?: string; timestamp?: number } {
+  if (!body) return {}
+  try {
+    const value = JSON.parse(body) as { reason?: unknown; timestamp?: unknown }
+    return {
+      ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
+      ...(typeof value.timestamp === 'number' ? { timestamp: value.timestamp } : {}),
+    }
+  } catch {
+    return { reason: body.slice(0, 200) }
+  }
+}
+
+function retryAfterMilliseconds(value: string | undefined, now: number): number | undefined {
+  if (!value) return undefined
+  const seconds = Number(value)
+  if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1_000)
+  const at = Date.parse(value)
+  if (Number.isNaN(at)) return undefined
+  return Math.max(0, at - now)
+}
+
+export function classifyAPNsResponse(
+  response: APNsTransportResponse,
+  now = Date.now(),
+): APNsSendResult {
+  const { reason, timestamp } = responseReason(response.body)
+  const apnsId = firstHeader(response.headers, 'apns-id')
+  const common = {
+    status: response.status,
+    ...(reason ? { reason } : {}),
+    ...(apnsId ? { apnsId } : {}),
+    ...(timestamp !== undefined ? { timestamp } : {}),
+  }
+  if (response.status === 0) return { disposition: 'retry', ...common }
+  if (response.status === 200) return { disposition: 'success', ...common }
+  if (response.status === 410 || (reason && UNREGISTER_REASONS.has(reason))) {
+    return { disposition: 'unregister', ...common }
+  }
+  if (response.status === 429 || response.status >= 500) {
+    const retryAfterMs = retryAfterMilliseconds(firstHeader(response.headers, 'retry-after'), now)
+    return { disposition: 'retry', ...common, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) }
+  }
+  if (response.status === 403 || (reason && TOKEN_CONFIGURATION_REASONS.has(reason))) {
+    return { disposition: 'configuration', ...common }
+  }
+  if (response.status === 400) return { disposition: 'failed', ...common }
+  return { disposition: 'failed', ...common }
+}
+
+export class NodeHttp2APNsTransport implements APNsTransport {
+  private readonly sessions = new Map<string, ClientHttp2Session>()
+
+  constructor(readonly timeoutMilliseconds = 15_000) {}
+
+  private session(endpoint: URL): ClientHttp2Session {
+    const origin = endpoint.origin
+    const existing = this.sessions.get(origin)
+    if (existing && !existing.closed && !existing.destroyed) return existing
+    const session = connect(origin)
+    this.sessions.set(origin, session)
+    const discard = () => {
+      if (this.sessions.get(origin) === session) this.sessions.delete(origin)
+    }
+    session.once('close', discard)
+    session.once('goaway', discard)
+    session.on('error', discard)
+    return session
+  }
+
+  send(endpoint: URL, headers: OutgoingHttpHeaders, body: Uint8Array): Promise<APNsTransportResponse> {
+    return new Promise((resolve, reject) => {
+      const stream = this.session(endpoint).request(headers)
+      let settled = false
+      const settle = <T>(finish: (value: T) => void, value: T) => {
+        if (settled) return
+        settled = true
+        clearTimeout(timeout)
+        finish(value)
+      }
+      const timeout = setTimeout(() => {
+        stream.close(http2Constants.NGHTTP2_CANCEL)
+        settle(reject, new Error('APNs request timed out'))
+      }, this.timeoutMilliseconds)
+      timeout.unref()
+      let responseHeaders: IncomingHttpHeaders | undefined
+      const chunks: Buffer[] = []
+      let size = 0
+      stream.once('response', headersValue => { responseHeaders = headersValue })
+      stream.on('data', (chunk: Buffer | string) => {
+        const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+        size += buffer.length
+        if (size <= 64 * 1_024) chunks.push(buffer)
+      })
+      stream.once('end', () => {
+        const status = Number(responseHeaders?.[http2Constants.HTTP2_HEADER_STATUS] ?? 0)
+        settle(resolve, { status, headers: responseHeaders, body: Buffer.concat(chunks).toString('utf8') })
+      })
+      stream.once('error', error => settle(reject, error))
+      stream.end(body)
+    })
+  }
+
+  close(): void {
+    for (const session of this.sessions.values()) session.destroy()
+    this.sessions.clear()
+  }
+}
+
+export class APNsProvider {
+  private readonly transport: APNsTransport
+  private readonly now: () => number
+  private key?: KeyObject
+  private token?: { value: string; createdAt: number }
+
+  constructor(
+    readonly config: APNsProviderConfig,
+    options: APNsProviderOptions = {},
+  ) {
+    this.transport = options.transport ?? new NodeHttp2APNsTransport()
+    this.now = options.now ?? Date.now
+  }
+
+  private authorizationToken(): string {
+    const now = this.now()
+    if (this.token && now - this.token.createdAt < PROVIDER_TOKEN_LIFETIME_MS) return this.token.value
+    this.key ??= createPrivateKey(readFileSync(this.config.keyFile))
+    const value = createAPNsProviderToken(this.config, now, this.key)
+    this.token = { value, createdAt: now }
+    return value
+  }
+
+  async send(request: APNsRequest): Promise<APNsSendResult> {
+    if (!TOKEN_RE.test(request.deviceToken)) {
+      return { disposition: 'unregister', status: 0, reason: 'BadDeviceToken' }
+    }
+    const body = Buffer.from(JSON.stringify(request.payload), 'utf8')
+    if (body.length > APNS_MAX_PAYLOAD_BYTES) {
+      return { disposition: 'failed', status: 0, reason: 'PayloadTooLarge' }
+    }
+    if (request.collapseId && Buffer.byteLength(request.collapseId, 'utf8') > 64) {
+      return { disposition: 'failed', status: 0, reason: 'BadCollapseId' }
+    }
+    let authorization: string
+    try {
+      authorization = this.authorizationToken()
+    } catch (cause) {
+      return {
+        disposition: 'configuration',
+        status: 0,
+        reason: cause instanceof Error ? cause.message : 'Unable to load APNs provider key',
+      }
+    }
+    const endpoint = request.environment === 'development'
+      ? APNS_DEVELOPMENT_ENDPOINT
+      : APNS_PRODUCTION_ENDPOINT
+    const apnsId = request.apnsId ?? randomUUID()
+    const headers: OutgoingHttpHeaders = {
+      [http2Constants.HTTP2_HEADER_METHOD]: 'POST',
+      [http2Constants.HTTP2_HEADER_PATH]: `/3/device/${request.deviceToken.toLowerCase()}`,
+      authorization: `bearer ${authorization}`,
+      'content-type': 'application/json',
+      'apns-topic': this.config.topic,
+      'apns-push-type': request.pushType ?? 'alert',
+      'apns-priority': String(request.priority ?? 10),
+      'apns-expiration': String(request.expiration ?? 0),
+      'apns-id': apnsId,
+      ...(request.collapseId ? { 'apns-collapse-id': request.collapseId } : {}),
+    }
+    try {
+      const result = classifyAPNsResponse(await this.transport.send(endpoint, headers, body), this.now())
+      if (result.disposition === 'configuration') this.token = undefined
+      return result
+    } catch (cause) {
+      return {
+        disposition: 'retry',
+        status: 0,
+        reason: cause instanceof Error ? cause.message : 'APNs connection failed',
+      }
+    }
+  }
+
+  close(): void {
+    this.transport.close?.()
+  }
+}

+ 57 - 1
src/server/app.ts

@@ -24,6 +24,14 @@ import { UpstreamHttpError, UpstreamClient } from './upstream.js'
 import { UploadStore } from './uploads.js'
 import { SystemUpdateManager } from './updateManager.js'
 import { installWebSocketRelay } from './websocket.js'
+import { PushCoordinator } from './pushCoordinator.js'
+import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
+import {
+  ChatPushJobManager,
+  GroupPushEventWatcher,
+  HermesChatNotificationResolver,
+  HermesGroupEventSource,
+} from './pushEvents.js'
 
 export interface ApplicationOptions {
   config?: ServerConfig
@@ -37,6 +45,7 @@ export interface ApplicationOptions {
   upstreamSession?: UpstreamServiceSession
   accountPairings?: AccountLoginPairingStore
   profileIdentities?: UpstreamProfileIdentityService
+  push?: PushCoordinator
 }
 
 export interface ApplicationRuntime {
@@ -52,6 +61,10 @@ export interface ApplicationRuntime {
   upstreamSession: UpstreamServiceSession
   accountPairings: AccountLoginPairingStore
   profileIdentities: UpstreamProfileIdentityService
+  push: PushCoordinator
+  pushEventCoordinator: PushCoordinatorEventAdapter
+  chatPushJobs: ChatPushJobManager
+  groupPushEvents: GroupPushEventWatcher
   close(): void
 }
 
@@ -106,6 +119,24 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
   const profileIdentities = options.profileIdentities
     ?? new UpstreamProfileIdentityService(config, upstreamSession)
+  const push = options.push ?? new PushCoordinator({
+    home: config.home,
+    apns: config.apns,
+    apnsConfigurationError: config.apnsConfigurationError,
+    isUserActive: userID => auth.isUserActive(userID),
+    userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
+  })
+  const pushEventCoordinator = new PushCoordinatorEventAdapter(push, upstreamSession)
+  const chatPushJobs = new ChatPushJobManager(config, upstreamSession, pushEventCoordinator, {
+    resolver: new HermesChatNotificationResolver(
+      upstreamSession,
+      (localUserID, prompt) => push.promptDigest(localUserID, prompt),
+    ),
+  })
+  const groupPushEvents = new GroupPushEventWatcher(
+    new HermesGroupEventSource(config, upstreamSession),
+    pushEventCoordinator,
+  )
   try {
     uploads.cleanupUncommitted()
   } catch {
@@ -219,6 +250,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     upstreamSession,
     accountPairings,
     profileIdentities,
+    push,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -245,7 +277,16 @@ export function createApplication(options: ApplicationOptions = {}): Application
     upstreamSession,
     accountPairings,
     profileIdentities,
-    close: () => uploads.close(),
+    push,
+    pushEventCoordinator,
+    chatPushJobs,
+    groupPushEvents,
+    close: () => {
+      chatPushJobs.stop()
+      groupPushEvents.stop()
+      push.close()
+      uploads.close()
+    },
   }
 }
 
@@ -262,11 +303,26 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         key: readFileSync(config.tlsKey),
       }, runtime.app.callback())
     : createHttpServer(runtime.app.callback())
+  const pushEnabled = runtime.push.capabilities().enabled
+  if (pushEnabled) {
+    runtime.chatPushJobs.start()
+    runtime.groupPushEvents.start()
+  }
   const removeWebSockets = installWebSocketRelay(
     server,
     config,
     runtime.leases,
     runtime.pairings,
+    pushEnabled ? {
+      coordinator: runtime.pushEventCoordinator,
+      resolveGatewayUser: request => runtime.auth.currentFromCookieHeader(
+        typeof request.headers.cookie === 'string' ? request.headers.cookie : undefined,
+      )?.id,
+      notificationResolver: new HermesChatNotificationResolver(
+        runtime.upstreamSession,
+        (localUserID, prompt) => runtime.push.promptDigest(localUserID, prompt),
+      ),
+    } : undefined,
   )
   return {
     server,

+ 51 - 1
src/server/config.ts

@@ -1,7 +1,15 @@
 import { isIP } from 'node:net'
 import { homedir } from 'node:os'
 import { basename, resolve } from 'node:path'
-import { readFileSync } from 'node:fs'
+import { createPrivateKey } from 'node:crypto'
+import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from 'node:fs'
+
+export interface APNsProviderConfig {
+  keyFile: string
+  keyId: string
+  teamId: string
+  topic: string
+}
 
 export interface ServerConfig {
   host: string
@@ -25,10 +33,13 @@ export interface ServerConfig {
   allowRemoteUpdate?: boolean
   upstreamUsername?: string
   upstreamPassword?: string
+  apns?: APNsProviderConfig
+  apnsConfigurationError?: string
 }
 
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
 export const DEFAULT_YAOYAO_RELEASE_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git'
+export const DEFAULT_APNS_TOPIC = 'cn.samien.yaoyao.hermes'
 
 function flag(value: string | undefined): boolean {
   return value === '1' || value?.toLowerCase() === 'true'
@@ -86,6 +97,43 @@ function parseReleaseSource(value: string | undefined): string {
   return source
 }
 
+function parseAPNsConfiguration(env: NodeJS.ProcessEnv): {
+  apns?: APNsProviderConfig
+  apnsConfigurationError?: string
+} {
+  const rawKeyFile = env.HERMES_YAOYAO_APNS_KEY_FILE?.trim()
+  const keyId = env.HERMES_YAOYAO_APNS_KEY_ID?.trim()
+  const teamId = env.HERMES_YAOYAO_APNS_TEAM_ID?.trim()
+  const topic = env.HERMES_YAOYAO_APNS_TOPIC?.trim() || DEFAULT_APNS_TOPIC
+  if (!rawKeyFile && !keyId && !teamId && !env.HERMES_YAOYAO_APNS_TOPIC?.trim()) return {}
+  if (!rawKeyFile || !keyId || !teamId) {
+    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE, HERMES_YAOYAO_APNS_KEY_ID, and HERMES_YAOYAO_APNS_TEAM_ID must be set together' }
+  }
+  if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
+    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_ID is invalid' }
+  }
+  if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) {
+    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_TEAM_ID is invalid' }
+  }
+  if (topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) {
+    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_TOPIC is invalid' }
+  }
+  const keyFile = resolve(rawKeyFile)
+  try {
+    if (!existsSync(keyFile) || !statSync(keyFile).isFile()) {
+      return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE must point to a readable file' }
+    }
+    accessSync(keyFile, fsConstants.R_OK)
+    const key = createPrivateKey(readFileSync(keyFile))
+    if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
+      return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE must contain an ES256 private key' }
+    }
+  } catch {
+    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE must contain a readable ES256 private key' }
+  }
+  return { apns: { keyFile, keyId, teamId, topic } }
+}
+
 function normalizeConfiguredHost(raw: string): string {
   const value = raw.trim().toLowerCase()
   if (!value) return ''
@@ -152,6 +200,7 @@ export function loadServerConfig(
   }
   const upstreamPassword = env.HERMES_YAOYAO_UPSTREAM_PASSWORD
     || (upstreamPasswordFile ? readFileSync(resolve(upstreamPasswordFile), 'utf8').replace(/[\r\n]+$/, '') : undefined)
+  const apnsConfiguration = parseAPNsConfiguration(env)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
@@ -186,5 +235,6 @@ export function loadServerConfig(
     allowRemoteUpdate,
     upstreamUsername,
     upstreamPassword,
+    ...apnsConfiguration,
   }
 }

+ 3 - 0
src/server/leases.ts

@@ -10,6 +10,7 @@ export interface RealtimeLease {
   credential: UpstreamCredential
   origin: string
   accountKeys: ReadonlySet<string>
+  localUserID?: string
   epoch?: string
   cursor?: number
   expiresAt: number
@@ -20,6 +21,7 @@ export interface IssueLeaseInput {
   credential: UpstreamCredential
   origin: string
   accountKeys: Iterable<string>
+  localUserID?: string
   epoch?: string
   cursor?: number
 }
@@ -49,6 +51,7 @@ export class RealtimeLeaseStore {
       credential: input.credential,
       origin: input.origin,
       accountKeys,
+      localUserID: input.localUserID,
       epoch: input.epoch,
       cursor: input.cursor,
       expiresAt: this.now() + this.ttlMs,

+ 15 - 1
src/server/localAuth.ts

@@ -163,7 +163,12 @@ export class LocalAuthStore {
   current(ctx: Koa.Context): LocalUser | undefined {
     const injected = ctx.state.localUser as LocalUser | undefined
     if (injected) return injected
-    const token = this.#token(ctx)
+    return this.currentFromCookieHeader(ctx.get('cookie'))
+  }
+
+  /** Resolve the same authenticated user for non-Koa transports such as WS Upgrade. */
+  currentFromCookieHeader(cookieHeader: string | undefined): LocalUser | undefined {
+    const token = parse(cookieHeader ?? '')[SESSION_COOKIE]
     if (!token) return undefined
     const key = this.#sessionKey(token)
     const session = this.#sessions.get(key)
@@ -196,6 +201,15 @@ export class LocalAuthStore {
     return user
   }
 
+  isUserActive(userID: string): boolean {
+    return this.#users.some(user => user.id === userID && user.enabled)
+  }
+
+  pushAuthorizationVersion(userID: string): number | undefined {
+    const user = this.#users.find(candidate => candidate.id === userID && candidate.enabled)
+    return user?.authVersion
+  }
+
   list(admin: LocalUser): LocalUser[] {
     if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
     return this.#users.map(publicUser).sort((a, b) => a.createdAt - b.createdAt)

+ 1207 - 0
src/server/pushCoordinator.ts

@@ -0,0 +1,1207 @@
+import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'
+import {
+  chmodSync,
+  closeSync,
+  existsSync,
+  fsyncSync,
+  mkdirSync,
+  openSync,
+  readFileSync,
+  renameSync,
+  unlinkSync,
+  writeFileSync,
+} from 'node:fs'
+import { dirname, join } from 'node:path'
+import {
+  APNsProvider,
+  type APNsEnvironment,
+  type APNsRequest,
+  type APNsSendResult,
+} from './apns.js'
+import { DEFAULT_APNS_TOPIC, type APNsProviderConfig } from './config.js'
+
+export const PUSH_EVENT_KINDS = [
+  'chat.completed',
+  'chat.failed',
+  'chat.approval.requested',
+  'chat.clarification.requested',
+  'group.message.completed',
+  'group.message.failed',
+  'group.interaction.requested',
+] as const
+
+export type PushEventKind = typeof PUSH_EVENT_KINDS[number]
+
+export interface PushCapabilities {
+  protocolVersion: 1
+  enabled: boolean
+  topic: string
+  previewMode: 'title-and-summary'
+  events: readonly PushEventKind[]
+  maxSummaryCharacters: 180
+  maximumSummaryCharacters: 180
+  configurationError?: string
+}
+
+export interface PushStatus {
+  configured: boolean
+  healthy: boolean
+  topic: string
+  registrationCount: number
+  pendingCount: number
+  subscriptionCount: number
+  lastSuccessAt?: string
+  lastError?: string
+  lastErrorAt?: string
+}
+
+export interface InstallationRegistration {
+  userId: string
+  installationId: string
+  clientAccountId: string
+  deviceToken: string
+  environment: APNsEnvironment
+  appVersion?: string
+  authorizationVersion?: number
+}
+
+export interface PushInstallation {
+  installationId: string
+  clientAccountId: string
+  environment: APNsEnvironment
+  appVersion?: string
+  updatedAt: string
+}
+
+export interface PushGroupSubscription {
+  roomId: string
+  enabled: boolean
+  updatedAt: string
+  lastMessageSeq?: number
+}
+
+export interface PushNotification {
+  eventId: string
+  userId: string
+  kind: PushEventKind
+  title: string
+  body: string
+  clientAccountId?: string
+  data?: Record<string, unknown>
+  collapseId?: string
+  threadId?: string
+  roomId?: string
+  expiresAt?: number
+}
+
+export interface PushGroupWatchAnchor {
+  epoch: string
+  cursor: number
+  updatedAt?: string
+  reason?: string
+}
+
+export interface PushChatJob {
+  id: string
+  localUserID: string
+  profile?: string
+  runtimeSessionID: string
+  storedSessionID?: string
+  requestID: string
+  queued: boolean
+  phase: 'submitted' | 'accepted' | 'watching'
+  submittedAt: number
+  expiresAt: number
+  metadata?: Record<string, unknown>
+}
+
+export interface PushNotificationCandidate {
+  eventID: string
+  localUserID: string
+  kind: PushEventKind
+  title: string
+  body: string
+  collapseID: string
+  data: Record<string, unknown>
+  roomID?: string
+  topicID?: string
+}
+
+export interface PushFlushResult {
+  delivered: number
+  retried: number
+  removedRegistrations: number
+  failed: number
+}
+
+export interface PushSender {
+  send(request: APNsRequest): Promise<APNsSendResult>
+  close?(): void
+}
+
+export interface PushCoordinatorOptions {
+  home: string
+  apns?: APNsProviderConfig
+  apnsConfigurationError?: string
+  provider?: PushSender
+  now?: () => number
+  autoFlush?: boolean
+  baseRetryMilliseconds?: number
+  maxRetryMilliseconds?: number
+  maxAttempts?: number
+  isUserActive?: (userId: string) => boolean
+  userAuthorizationVersion?: (userId: string) => number | undefined
+}
+
+interface InstallationRecord extends PushInstallation {
+  userId: string
+  deviceToken: string
+  badge: number
+  authorizationVersion?: number
+}
+
+interface GroupSubscriptionRecord extends PushGroupSubscription {
+  userId: string
+}
+
+interface OutboxRecord {
+  id: string
+  eventId: string
+  userId: string
+  installationId: string
+  clientAccountId: string
+  kind: PushEventKind
+  title: string
+  body: string
+  data: Record<string, unknown>
+  collapseId?: string
+  threadId?: string
+  roomId?: string
+  createdAt: number
+  expiresAt: number
+  attempts: number
+  nextAttemptAt: number
+}
+
+interface StoredStatus {
+  lastSuccessAt?: string
+  lastError?: string
+  lastErrorAt?: string
+}
+
+interface PushState {
+  schemaVersion: 1
+  installations: InstallationRecord[]
+  groupSubscriptions: GroupSubscriptionRecord[]
+  outbox: OutboxRecord[]
+  processedEvents: Record<string, number>
+  groupWatch?: PushGroupWatchAnchor
+  chatJobs: PushChatJob[]
+  ambiguousChatSessions: Record<string, number>
+  chatRecoveryDisabledUntil?: number
+  status: StoredStatus
+}
+
+const MAX_SUMMARY_CHARACTERS = 180
+const MAX_OUTBOX_ITEMS = 10_000
+const MAX_GROUP_SUBSCRIPTIONS_PER_USER = 512
+const MAX_INSTALLATIONS_PER_USER = 32
+const MAX_CHAT_JOBS_PER_USER = 256
+const MAX_PROCESSED_EVENTS = 50_000
+const MAX_AMBIGUOUS_CHAT_SESSIONS = 50_000
+const AMBIGUOUS_CHAT_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1_000
+const DEFAULT_EVENT_LIFETIME_MS = 24 * 60 * 60 * 1_000
+const DEDUPE_LIFETIME_MS = 90 * 24 * 60 * 60 * 1_000
+
+function emptyState(): PushState {
+  return {
+    schemaVersion: 1,
+    installations: [],
+    groupSubscriptions: [],
+    outbox: [],
+    processedEvents: {},
+    chatJobs: [],
+    ambiguousChatSessions: {},
+    status: {},
+  }
+}
+
+function isObject(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function parseState(raw: string): PushState {
+  const value = JSON.parse(raw) as unknown
+  if (!isObject(value)
+    || value.schemaVersion !== 1
+    || !Array.isArray(value.installations)
+    || !Array.isArray(value.groupSubscriptions)
+    || !Array.isArray(value.outbox)
+    || !isObject(value.processedEvents)
+    || !isObject(value.status)) {
+    throw new Error('Unsupported or malformed push state')
+  }
+  if (value.chatJobs !== undefined && !Array.isArray(value.chatJobs)) {
+    throw new Error('Malformed push chat jobs')
+  }
+  if (value.ambiguousChatSessions !== undefined && !isObject(value.ambiguousChatSessions)) {
+    throw new Error('Malformed ambiguous push chat sessions')
+  }
+  const legacyOwners = isObject(value.chatSessionOwners)
+    ? value.chatSessionOwners as Record<string, unknown> : {}
+  const migratedAmbiguous = Object.fromEntries(Object.entries(legacyOwners).flatMap(([sessionID, owners]) => (
+    Array.isArray(owners) && new Set(owners.map(String)).size > 1 ? [[sessionID, Date.now()]] : []
+  )))
+  return {
+    schemaVersion: 1,
+    installations: value.installations as InstallationRecord[],
+    groupSubscriptions: value.groupSubscriptions as GroupSubscriptionRecord[],
+    outbox: value.outbox as OutboxRecord[],
+    processedEvents: value.processedEvents as Record<string, number>,
+    ...(isObject(value.groupWatch) ? { groupWatch: value.groupWatch as unknown as PushGroupWatchAnchor } : {}),
+    chatJobs: (value.chatJobs ?? []) as PushChatJob[],
+    ambiguousChatSessions: {
+      ...migratedAmbiguous,
+      ...(value.ambiguousChatSessions ?? {}) as Record<string, number>,
+    },
+    ...(typeof value.chatRecoveryDisabledUntil === 'number'
+      ? { chatRecoveryDisabledUntil: value.chatRecoveryDisabledUntil }
+      : {}),
+    status: value.status as StoredStatus,
+  }
+}
+
+function cloneState(state: PushState): PushState {
+  return JSON.parse(JSON.stringify(state)) as PushState
+}
+
+class PushStateStore {
+  private state = emptyState()
+  readonly path: string
+  readonly loadError?: string
+
+  constructor(home: string) {
+    this.path = join(home, 'push', 'state.json')
+    if (!existsSync(this.path)) return
+    try {
+      chmodSync(this.path, 0o600)
+      this.state = parseState(readFileSync(this.path, 'utf8'))
+    } catch {
+      this.loadError = '推送状态文件无法读取;为避免覆盖现有数据,推送已停用'
+    }
+  }
+
+  snapshot(): PushState {
+    return cloneState(this.state)
+  }
+
+  mutate<T>(operation: (state: PushState) => T): T {
+    if (this.loadError) throw new Error(this.loadError)
+    const next = cloneState(this.state)
+    const result = operation(next)
+    this.write(next)
+    this.state = next
+    return result
+  }
+
+  private write(state: PushState): void {
+    const directory = dirname(this.path)
+    mkdirSync(directory, { recursive: true, mode: 0o700 })
+    const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`
+    let descriptor: number | undefined
+    try {
+      descriptor = openSync(temporary, 'wx', 0o600)
+      writeFileSync(descriptor, `${JSON.stringify(state)}\n`, 'utf8')
+      fsyncSync(descriptor)
+      closeSync(descriptor)
+      descriptor = undefined
+      renameSync(temporary, this.path)
+      chmodSync(this.path, 0o600)
+    } catch (cause) {
+      if (descriptor !== undefined) closeSync(descriptor)
+      try { unlinkSync(temporary) } catch { /* Nothing to clean up. */ }
+      throw cause
+    }
+  }
+}
+
+function requiredIdentifier(value: string, name: string, maximum = 256): string {
+  const normalized = value.trim()
+  if (!normalized || normalized.length > maximum || /[\u0000-\u001f\u007f]/.test(normalized)) {
+    throw new Error(`${name} is invalid`)
+  }
+  return normalized
+}
+
+function normalizedToken(value: string): string {
+  const normalized = value.trim().toLowerCase()
+  if (!/^(?:[a-f0-9]{2}){16,256}$/.test(normalized)) throw new Error('deviceToken is invalid')
+  return normalized
+}
+
+function limitedText(value: string, maximum: number, name: string): string {
+  const normalized = value.trim()
+  if (!normalized) throw new Error(`${name} is required`)
+  return Array.from(normalized).slice(0, maximum).join('')
+}
+
+function publicInstallation(record: InstallationRecord): PushInstallation {
+  return {
+    installationId: record.installationId,
+    clientAccountId: record.clientAccountId,
+    environment: record.environment,
+    ...(record.appVersion ? { appVersion: record.appVersion } : {}),
+    updatedAt: record.updatedAt,
+  }
+}
+
+function dedupePrefix(userId: string): string {
+  return `${Buffer.from(userId, 'utf8').toString('base64url')}.`
+}
+
+function dedupeKey(userId: string, eventId: string): string {
+  const digest = createHash('sha256').update(eventId, 'utf8').digest('base64url')
+  return `${dedupePrefix(userId)}${digest}`
+}
+
+function safeError(result: APNsSendResult): string {
+  const reason = result.reason || `APNs HTTP ${result.status}`
+  return reason.replace(/(?:[a-fA-F0-9]{2}){16,256}/g, '[device-token]').replace(/[\r\n]+/g, ' ').slice(0, 500)
+}
+
+function canonicalGroupWatchAnchor(input: PushGroupWatchAnchor, now: number): PushGroupWatchAnchor {
+  const epoch = requiredIdentifier(input.epoch, 'epoch', 128)
+  if (!Number.isSafeInteger(input.cursor) || input.cursor < 0) throw new Error('cursor is invalid')
+  return {
+    epoch,
+    cursor: input.cursor,
+    updatedAt: new Date(now).toISOString(),
+    ...(input.reason ? { reason: limitedText(input.reason, 200, 'reason') } : {}),
+  }
+}
+
+function canonicalChatJob(input: PushChatJob): PushChatJob {
+  const id = requiredIdentifier(input.id, 'job.id', 512)
+  const localUserID = requiredIdentifier(input.localUserID, 'job.localUserID')
+  const runtimeSessionID = requiredIdentifier(input.runtimeSessionID, 'job.runtimeSessionID', 512)
+  const requestID = requiredIdentifier(input.requestID, 'job.requestID', 512)
+  if (!['submitted', 'accepted', 'watching'].includes(input.phase)) throw new Error('job.phase is invalid')
+  if (!Number.isFinite(input.submittedAt) || !Number.isFinite(input.expiresAt) || input.expiresAt <= input.submittedAt) {
+    throw new Error('job timestamps are invalid')
+  }
+  let metadata: Record<string, unknown> | undefined
+  if (input.metadata) metadata = JSON.parse(JSON.stringify(input.metadata)) as Record<string, unknown>
+  return {
+    id,
+    localUserID,
+    ...(input.profile ? { profile: requiredIdentifier(input.profile, 'job.profile') } : {}),
+    runtimeSessionID,
+    ...(input.storedSessionID
+      ? { storedSessionID: requiredIdentifier(input.storedSessionID, 'job.storedSessionID', 512) }
+      : {}),
+    requestID,
+    queued: Boolean(input.queued),
+    phase: input.phase,
+    submittedAt: input.submittedAt,
+    expiresAt: input.expiresAt,
+    ...(metadata ? { metadata } : {}),
+  }
+}
+
+export class PushCoordinator {
+  private readonly store: PushStateStore
+  private readonly provider?: PushSender
+  private readonly now: () => number
+  private readonly autoFlush: boolean
+  private readonly baseRetryMilliseconds: number
+  private readonly maxRetryMilliseconds: number
+  private readonly maxAttempts: number
+  private readonly isUserActive: (userId: string) => boolean
+  private readonly userAuthorizationVersion?: (userId: string) => number | undefined
+  private timer?: NodeJS.Timeout
+  private activeFlush?: Promise<PushFlushResult>
+  private closed = false
+  private runtimeError?: { message: string; at: string }
+  private flushFailureCount = 0
+  private flushBlockedUntil = 0
+  private correlationSecret?: Buffer
+  private correlationError?: string
+
+  constructor(readonly options: PushCoordinatorOptions) {
+    this.store = new PushStateStore(options.home)
+    this.provider = options.provider ?? (options.apns ? new APNsProvider(options.apns) : undefined)
+    this.now = options.now ?? Date.now
+    this.autoFlush = options.autoFlush ?? true
+    this.baseRetryMilliseconds = options.baseRetryMilliseconds ?? 1_000
+    this.maxRetryMilliseconds = options.maxRetryMilliseconds ?? 60 * 60 * 1_000
+    this.maxAttempts = options.maxAttempts ?? 36
+    this.isUserActive = options.isUserActive ?? (() => true)
+    this.userAuthorizationVersion = options.userAuthorizationVersion
+    if (options.apns && this.provider && !options.apnsConfigurationError) {
+      try { this.loadCorrelationSecret() } catch (cause) {
+        this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
+      }
+    }
+    this.markPersistedJobsDisconnected()
+    this.scheduleNextFlush()
+  }
+
+  capabilities(): PushCapabilities {
+    const configurationError = this.options.apnsConfigurationError ?? this.store.loadError
+    return {
+      protocolVersion: 1,
+      enabled: Boolean(this.options.apns && this.provider && !configurationError),
+      topic: this.options.apns?.topic ?? DEFAULT_APNS_TOPIC,
+      previewMode: 'title-and-summary',
+      events: PUSH_EVENT_KINDS,
+      maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
+      maximumSummaryCharacters: MAX_SUMMARY_CHARACTERS,
+      ...(configurationError ? { configurationError } : {}),
+    }
+  }
+
+  status(): PushStatus {
+    const state = this.store.snapshot()
+    const configurationError = this.options.apnsConfigurationError ?? this.store.loadError
+    const latestSuccess = state.status.lastSuccessAt ? Date.parse(state.status.lastSuccessAt) : 0
+    const latestError = state.status.lastErrorAt ? Date.parse(state.status.lastErrorAt) : 0
+    const configured = Boolean(this.options.apns && this.provider && !configurationError)
+    return {
+      configured,
+      healthy: configured && !this.correlationError && !this.runtimeError && latestSuccess >= latestError,
+      topic: this.options.apns?.topic ?? DEFAULT_APNS_TOPIC,
+      registrationCount: state.installations.length,
+      pendingCount: state.outbox.length,
+      subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
+      ...(state.status.lastSuccessAt ? { lastSuccessAt: state.status.lastSuccessAt } : {}),
+      ...(configurationError || this.correlationError || this.runtimeError?.message || state.status.lastError
+        ? { lastError: configurationError ?? this.correlationError ?? this.runtimeError?.message ?? state.status.lastError }
+        : {}),
+      ...(this.runtimeError?.at || state.status.lastErrorAt
+        ? { lastErrorAt: this.runtimeError?.at ?? state.status.lastErrorAt }
+        : {}),
+    }
+  }
+
+  registerInstallation(input: InstallationRegistration): PushInstallation {
+    const userId = requiredIdentifier(input.userId, 'userId')
+    const installationId = requiredIdentifier(input.installationId, 'installationId')
+    const clientAccountId = requiredIdentifier(input.clientAccountId, 'clientAccountId')
+    const deviceToken = normalizedToken(input.deviceToken)
+    if (input.environment !== 'development' && input.environment !== 'production') {
+      throw new Error('environment is invalid')
+    }
+    const appVersion = input.appVersion
+      ? requiredIdentifier(input.appVersion, 'appVersion', 64)
+      : undefined
+    const authorizationVersion = input.authorizationVersion
+      ?? this.userAuthorizationVersion?.(userId)
+    if (authorizationVersion !== undefined
+      && (!Number.isSafeInteger(authorizationVersion) || authorizationVersion < 1)) {
+      throw new Error('authorizationVersion is invalid')
+    }
+    const updatedAt = new Date(this.now()).toISOString()
+    const installation = this.store.mutate(state => {
+      const existing = state.installations.find(item => item.userId === userId
+        && item.installationId === installationId
+        && item.clientAccountId === clientAccountId)
+      if (existing) {
+        existing.deviceToken = deviceToken
+        existing.environment = input.environment
+        existing.updatedAt = updatedAt
+        if (authorizationVersion !== undefined) existing.authorizationVersion = authorizationVersion
+        else delete existing.authorizationVersion
+        if (appVersion) existing.appVersion = appVersion
+        else delete existing.appVersion
+        return publicInstallation(existing)
+      }
+      if (state.installations.filter(item => item.userId === userId).length >= MAX_INSTALLATIONS_PER_USER) {
+        throw new Error('Push installation limit reached')
+      }
+      const record: InstallationRecord = {
+        userId,
+        installationId,
+        clientAccountId,
+        deviceToken,
+        environment: input.environment,
+        ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
+        ...(appVersion ? { appVersion } : {}),
+        badge: 0,
+        updatedAt,
+      }
+      state.installations.push(record)
+      return publicInstallation(record)
+    })
+    this.scheduleNextFlush()
+    return installation
+  }
+
+  unregisterInstallation(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): boolean {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const installationId = requiredIdentifier(installationIdValue, 'installationId')
+    const clientAccountId = requiredIdentifier(clientAccountIdValue, 'clientAccountId')
+    return this.store.mutate(state => {
+      const before = state.installations.length
+      state.installations = state.installations.filter(item => !(item.userId === userId
+        && item.installationId === installationId
+        && item.clientAccountId === clientAccountId))
+      state.outbox = state.outbox.filter(item => !(item.userId === userId
+        && item.installationId === installationId
+        && item.clientAccountId === clientAccountId))
+      return state.installations.length !== before
+    })
+  }
+
+  resetBadge(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): number {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const installationId = requiredIdentifier(installationIdValue, 'installationId')
+    const clientAccountId = requiredIdentifier(clientAccountIdValue, 'clientAccountId')
+    return this.store.mutate(state => {
+      const installation = state.installations.find(item => item.userId === userId
+        && item.installationId === installationId
+        && item.clientAccountId === clientAccountId)
+      if (installation) installation.badge = 0
+      return 0
+    })
+  }
+
+  listGroupSubscriptions(userIdValue: string): PushGroupSubscription[] {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    return this.store.snapshot().groupSubscriptions
+      .filter(item => item.userId === userId)
+      .map(({ roomId, enabled, updatedAt, lastMessageSeq }) => ({
+        roomId, enabled, updatedAt, ...(lastMessageSeq !== undefined ? { lastMessageSeq } : {}),
+      }))
+      .sort((left, right) => left.roomId.localeCompare(right.roomId))
+  }
+
+  setGroupSubscription(
+    userIdValue: string,
+    roomIdValue: string,
+    enabled: boolean,
+    baselineMessageSeq?: number,
+  ): PushGroupSubscription {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const roomId = requiredIdentifier(roomIdValue, 'roomId')
+    if (typeof enabled !== 'boolean') throw new Error('enabled must be a boolean')
+    if (baselineMessageSeq !== undefined && (!Number.isSafeInteger(baselineMessageSeq) || baselineMessageSeq < 0)) {
+      throw new Error('baselineMessageSeq is invalid')
+    }
+    const updatedAt = new Date(this.now()).toISOString()
+    return this.store.mutate(state => {
+      const existing = state.groupSubscriptions.find(item => item.userId === userId && item.roomId === roomId)
+      if (existing) {
+        if (!enabled) {
+          state.groupSubscriptions = state.groupSubscriptions.filter(item => !(item.userId === userId && item.roomId === roomId))
+          state.outbox = state.outbox.filter(item => !(item.userId === userId && item.roomId === roomId))
+          return { roomId, enabled: false, updatedAt }
+        }
+        if (existing.enabled === enabled) {
+          return {
+            roomId, enabled: existing.enabled, updatedAt: existing.updatedAt,
+            ...(existing.lastMessageSeq !== undefined ? { lastMessageSeq: existing.lastMessageSeq } : {}),
+          }
+        }
+        existing.enabled = enabled
+        existing.updatedAt = updatedAt
+        if (enabled && baselineMessageSeq !== undefined) existing.lastMessageSeq = baselineMessageSeq
+      } else {
+        if (!enabled) return { roomId, enabled: false, updatedAt }
+        const count = state.groupSubscriptions.filter(item => item.userId === userId && item.enabled).length
+        if (count >= MAX_GROUP_SUBSCRIPTIONS_PER_USER) throw new Error('Group push subscription limit reached')
+        state.groupSubscriptions.push({
+          userId, roomId, enabled, updatedAt,
+          ...(enabled && baselineMessageSeq !== undefined ? { lastMessageSeq: baselineMessageSeq } : {}),
+        })
+      }
+      const saved = state.groupSubscriptions.find(item => item.userId === userId && item.roomId === roomId)!
+      return {
+        roomId, enabled, updatedAt,
+        ...(saved.lastMessageSeq !== undefined ? { lastMessageSeq: saved.lastMessageSeq } : {}),
+      }
+    })
+  }
+
+  isGroupSubscribed(userIdValue: string, roomIdValue: string): boolean {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const roomId = requiredIdentifier(roomIdValue, 'roomId')
+    return this.store.snapshot().groupSubscriptions.some(item => item.userId === userId
+      && item.roomId === roomId
+      && item.enabled)
+  }
+
+  groupSubscriptionStartedAt(userIdValue: string, roomIdValue: string): number | undefined {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const roomId = requiredIdentifier(roomIdValue, 'roomId')
+    const subscription = this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
+      && item.roomId === roomId
+      && item.enabled)
+    if (!subscription) return undefined
+    const value = Date.parse(subscription.updatedAt)
+    return Number.isNaN(value) ? undefined : value
+  }
+
+  groupSubscriptionLastMessageSeq(userIdValue: string, roomIdValue: string): number | undefined {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const roomId = requiredIdentifier(roomIdValue, 'roomId')
+    return this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
+      && item.roomId === roomId
+      && item.enabled)?.lastMessageSeq
+  }
+
+  advanceGroupSubscriptionMessageSeq(userIdValue: string, roomIdValue: string, sequence: number): void {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const roomId = requiredIdentifier(roomIdValue, 'roomId')
+    if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error('message sequence is invalid')
+    const snapshot = this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
+      && item.roomId === roomId
+      && item.enabled)
+    if (!snapshot || (snapshot.lastMessageSeq ?? -1) >= sequence) return
+    this.store.mutate(state => {
+      const subscription = state.groupSubscriptions.find(item => item.userId === userId
+        && item.roomId === roomId
+        && item.enabled)
+      if (subscription && (subscription.lastMessageSeq ?? -1) < sequence) subscription.lastMessageSeq = sequence
+    })
+  }
+
+  subscribedUserIds(roomIdValue: string): string[] {
+    const roomId = requiredIdentifier(roomIdValue, 'roomId')
+    return [...new Set(this.store.snapshot().groupSubscriptions
+      .filter(item => item.roomId === roomId && item.enabled)
+      .map(item => item.userId))]
+  }
+
+  activeGroupSubscriptions(): Array<PushGroupSubscription & { userId: string }> {
+    return this.store.snapshot().groupSubscriptions
+      .filter(item => item.enabled)
+      .map(({ userId, roomId, enabled, updatedAt, lastMessageSeq }) => ({
+        userId, roomId, enabled, updatedAt,
+        ...(lastMessageSeq !== undefined ? { lastMessageSeq } : {}),
+      }))
+      .sort((left, right) => left.roomId.localeCompare(right.roomId) || left.userId.localeCompare(right.userId))
+  }
+
+  groupSubscribers(roomIdValue: string): readonly string[] {
+    return this.subscribedUserIds(roomIdValue)
+  }
+
+  groupWatchAnchor(): PushGroupWatchAnchor | undefined {
+    const anchor = this.store.snapshot().groupWatch
+    return anchor ? { ...anchor } : undefined
+  }
+
+  resetGroupWatch(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
+    const next = canonicalGroupWatchAnchor(anchor, this.now())
+    return this.store.mutate(state => {
+      state.groupWatch = next
+      return { ...next }
+    })
+  }
+
+  resetGroupCursor(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
+    return this.resetGroupWatch(anchor)
+  }
+
+  advanceGroupWatch(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
+    const next = canonicalGroupWatchAnchor(anchor, this.now())
+    return this.store.mutate(state => {
+      if (state.groupWatch && state.groupWatch.epoch !== next.epoch) {
+        throw new Error('group watch epoch changed without reset')
+      }
+      if (state.groupWatch && next.cursor < state.groupWatch.cursor) {
+        throw new Error('group watch cursor cannot move backwards')
+      }
+      state.groupWatch = next
+      return { ...next }
+    })
+  }
+
+  advanceGroupCursor(anchor: PushGroupWatchAnchor): void {
+    this.advanceGroupWatch(anchor)
+  }
+
+  upsertChatJob(input: PushChatJob): PushChatJob {
+    const job = canonicalChatJob(input)
+    return this.store.mutate(state => {
+      const existingIndex = state.chatJobs.findIndex(item => item.id === job.id)
+      if (existingIndex >= 0 && state.chatJobs[existingIndex]!.localUserID !== job.localUserID) {
+        throw new Error('job.id is already owned by another user')
+      }
+      if (existingIndex >= 0) state.chatJobs[existingIndex] = job
+      else {
+        if (state.chatJobs.filter(item => item.localUserID === job.localUserID).length >= MAX_CHAT_JOBS_PER_USER) {
+          throw new Error('Chat push job limit reached')
+        }
+        state.chatJobs.push(job)
+      }
+      if (job.storedSessionID) {
+        for (const [sessionID, observedAt] of Object.entries(state.ambiguousChatSessions)) {
+          if (!Number.isFinite(observedAt) || observedAt < this.now() - AMBIGUOUS_CHAT_SESSION_TTL_MS) {
+            delete state.ambiguousChatSessions[sessionID]
+          }
+        }
+        const owners = new Set(state.chatJobs
+          .filter(item => item.storedSessionID === job.storedSessionID)
+          .map(item => item.localUserID))
+        if (owners.size > 1) {
+          const entries = Object.entries(state.ambiguousChatSessions)
+          if (entries.length >= MAX_AMBIGUOUS_CHAT_SESSIONS
+            && state.ambiguousChatSessions[job.storedSessionID] === undefined) {
+            state.chatRecoveryDisabledUntil = this.now() + AMBIGUOUS_CHAT_SESSION_TTL_MS
+          } else {
+            state.ambiguousChatSessions[job.storedSessionID] = this.now()
+          }
+        }
+      }
+      return { ...job }
+    })
+  }
+
+  saveChatJob(input: PushChatJob): void {
+    this.upsertChatJob(input)
+  }
+
+  listChatJobs(userIdValue?: string): PushChatJob[] {
+    const userId = userIdValue ? requiredIdentifier(userIdValue, 'userId') : undefined
+    const now = this.now()
+    return this.store.snapshot().chatJobs
+      .filter(item => item.expiresAt > now && (!userId || item.localUserID === userId))
+      .sort((left, right) => left.submittedAt - right.submittedAt)
+      .map(item => ({ ...item, ...(item.metadata ? { metadata: { ...item.metadata } } : {}) }))
+  }
+
+  pendingChatJobs(): readonly PushChatJob[] {
+    const now = this.now()
+    const snapshot = this.store.snapshot()
+    if (snapshot.chatJobs.some(item => item.expiresAt <= now)) {
+      this.store.mutate(state => {
+        state.chatJobs = state.chatJobs.filter(item => item.expiresAt > now)
+      })
+    }
+    return this.listChatJobs()
+  }
+
+  chatJobRecoveryAllowed(job: PushChatJob): boolean {
+    if (!job.storedSessionID) return false
+    const state = this.store.snapshot()
+    if ((state.chatRecoveryDisabledUntil ?? 0) > this.now()) return false
+    if (state.ambiguousChatSessions[job.storedSessionID] !== undefined) return false
+    const owners = new Set(state.chatJobs
+      .filter(item => item.storedSessionID === job.storedSessionID)
+      .map(item => item.localUserID))
+    return owners.size === 1 && owners.has(job.localUserID)
+  }
+
+  promptDigest(userIdValue: string, prompt: string): string {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const normalized = prompt.trim().replace(/\r\n/g, '\n')
+    if (!normalized || normalized.length > 200_000) throw new Error('prompt is invalid')
+    if (this.correlationError) throw new Error(this.correlationError)
+    return createHmac('sha256', this.loadCorrelationSecret())
+      .update(userId, 'utf8')
+      .update('\0', 'utf8')
+      .update(normalized, 'utf8')
+      .digest('base64url')
+  }
+
+  completeChatJob(jobId: string): void
+  completeChatJob(userId: string, jobId: string): void
+  completeChatJob(userOrJobId: string, optionalJobId?: string): void {
+    const jobId = requiredIdentifier(optionalJobId ?? userOrJobId, 'jobId', 512)
+    const userId = optionalJobId ? requiredIdentifier(userOrJobId, 'userId') : undefined
+    this.store.mutate(state => {
+      state.chatJobs = state.chatJobs.filter(item => !(item.id === jobId && (!userId || item.localUserID === userId)))
+    })
+  }
+
+  removeUser(userIdValue: string): { registrations: number; subscriptions: number; pending: number } {
+    const userId = requiredIdentifier(userIdValue, 'userId')
+    const prefix = dedupePrefix(userId)
+    return this.store.mutate(state => {
+      const registrations = state.installations.filter(item => item.userId === userId).length
+      const subscriptions = state.groupSubscriptions.filter(item => item.userId === userId).length
+      const pending = state.outbox.filter(item => item.userId === userId).length
+      state.installations = state.installations.filter(item => item.userId !== userId)
+      state.groupSubscriptions = state.groupSubscriptions.filter(item => item.userId !== userId)
+      state.outbox = state.outbox.filter(item => item.userId !== userId)
+      state.chatJobs = state.chatJobs.filter(item => item.localUserID !== userId)
+      for (const key of Object.keys(state.processedEvents)) {
+        if (key.startsWith(prefix)) delete state.processedEvents[key]
+      }
+      return { registrations, subscriptions, pending }
+    })
+  }
+
+  enqueueNotification(input: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
+    const userId = requiredIdentifier(input.localUserID, 'localUserID')
+    const eventId = requiredIdentifier(input.eventID, 'eventID', 512)
+    if (this.store.snapshot().processedEvents[dedupeKey(userId, eventId)] !== undefined) return 'duplicate'
+    const count = this.enqueue({
+      eventId,
+      userId,
+      kind: input.kind,
+      title: input.title,
+      body: input.body,
+      data: input.data,
+      collapseId: input.collapseID,
+      ...(input.roomID ? { roomId: input.roomID } : {}),
+      ...((input.topicID || input.roomID) ? { threadId: input.topicID ?? input.roomID } : {}),
+    })
+    return count > 0 ? 'enqueued' : 'ignored'
+  }
+
+  enqueueGroupEvent(input: PushNotificationCandidate & { roomID: string }): 'enqueued' | 'duplicate' | 'ignored' {
+    if (!this.isGroupSubscribed(input.localUserID, input.roomID)) return 'ignored'
+    return this.enqueueNotification(input)
+  }
+
+  enqueueChatCandidate(input: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
+    return this.enqueueNotification(input)
+  }
+
+  enqueue(input: PushNotification): number {
+    if (!this.capabilities().enabled) return 0
+    const eventId = requiredIdentifier(input.eventId, 'eventId', 512)
+    const userId = requiredIdentifier(input.userId, 'userId')
+    try {
+      if (!this.isUserActive(userId)) return 0
+    } catch {
+      return 0
+    }
+    if (!PUSH_EVENT_KINDS.includes(input.kind)) throw new Error('kind is invalid')
+    const title = limitedText(input.title, 120, 'title')
+    const body = limitedText(input.body, MAX_SUMMARY_CHARACTERS, 'body')
+    const clientAccountId = input.clientAccountId
+      ? requiredIdentifier(input.clientAccountId, 'clientAccountId')
+      : undefined
+    const roomId = input.roomId ? requiredIdentifier(input.roomId, 'roomId') : undefined
+    const collapseId = input.collapseId ? requiredIdentifier(input.collapseId, 'collapseId', 64) : undefined
+    if (collapseId && Buffer.byteLength(collapseId, 'utf8') > 64) throw new Error('collapseId is too long')
+    const threadId = input.threadId ? requiredIdentifier(input.threadId, 'threadId', 512) : undefined
+    let data: Record<string, unknown> = {}
+    if (input.data) {
+      const encoded = JSON.stringify(input.data)
+      if (encoded === undefined) throw new Error('data must be JSON serializable')
+      data = JSON.parse(encoded) as Record<string, unknown>
+    }
+    const now = this.now()
+    const expiresAt = input.expiresAt ?? now + DEFAULT_EVENT_LIFETIME_MS
+    if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new Error('expiresAt must be in the future')
+    const queued = this.store.mutate(state => {
+      for (const [key, processedAt] of Object.entries(state.processedEvents)) {
+        if (!Number.isFinite(processedAt) || processedAt < now - DEDUPE_LIFETIME_MS) delete state.processedEvents[key]
+      }
+      const processed = Object.entries(state.processedEvents)
+      if (processed.length >= MAX_PROCESSED_EVENTS) {
+        processed.sort((left, right) => left[1] - right[1])
+        for (const [expiredKey] of processed.slice(0, processed.length - MAX_PROCESSED_EVENTS + 1)) {
+          delete state.processedEvents[expiredKey]
+        }
+      }
+      const key = dedupeKey(userId, eventId)
+      if (state.processedEvents[key] !== undefined) return 0
+      const targets = state.installations.filter(item => item.userId === userId
+        && this.registrationIsAuthorized(item)
+        && (!clientAccountId || item.clientAccountId === clientAccountId))
+      if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
+      state.processedEvents[key] = now
+      for (const target of targets) {
+        target.badge += 1
+        state.outbox.push({
+          id: randomUUID(),
+          eventId,
+          userId,
+          installationId: target.installationId,
+          clientAccountId: target.clientAccountId,
+          kind: input.kind,
+          title,
+          body,
+          data,
+          ...(collapseId ? { collapseId } : {}),
+          ...(threadId ? { threadId } : {}),
+          ...(roomId ? { roomId } : {}),
+          createdAt: now,
+          expiresAt,
+          attempts: 0,
+          nextAttemptAt: now,
+        })
+      }
+      return targets.length
+    })
+    this.scheduleNextFlush()
+    return queued
+  }
+
+  flushDue(): Promise<PushFlushResult> {
+    if (this.activeFlush) return this.activeFlush
+    if (this.closed || !this.provider || !this.capabilities().enabled) {
+      return Promise.resolve({ delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 })
+    }
+    this.activeFlush = this.flushInternal()
+      .then(result => {
+        this.flushFailureCount = 0
+        this.flushBlockedUntil = 0
+        this.runtimeError = undefined
+        return result
+      })
+      .catch(cause => {
+        this.flushFailureCount += 1
+        const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount - 1, 6)))
+        this.flushBlockedUntil = this.now() + delay
+        this.recordRuntimeError(cause)
+        throw cause
+      })
+      .finally(() => {
+        this.activeFlush = undefined
+        this.scheduleNextFlush()
+      })
+    return this.activeFlush
+  }
+
+  private async flushInternal(): Promise<PushFlushResult> {
+    const summary: PushFlushResult = { delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 }
+    const provider = this.provider
+    if (!provider) return summary
+    const due = this.store.snapshot().outbox
+      .filter(item => item.nextAttemptAt <= this.now())
+      .sort((left, right) => left.nextAttemptAt - right.nextAttemptAt)
+      .slice(0, 100)
+    for (const candidate of due) {
+      if (this.closed) break
+      const state = this.store.snapshot()
+      const item = state.outbox.find(current => current.id === candidate.id)
+      if (!item) continue
+      let userActive = false
+      try { userActive = this.isUserActive(item.userId) } catch { userActive = false }
+      if (!userActive) {
+        this.store.mutate(current => {
+          current.installations = current.installations.filter(entry => entry.userId !== item.userId)
+          current.groupSubscriptions = current.groupSubscriptions.filter(entry => entry.userId !== item.userId)
+          current.outbox = current.outbox.filter(entry => entry.userId !== item.userId)
+          current.chatJobs = current.chatJobs.filter(entry => entry.localUserID !== item.userId)
+        })
+        summary.failed += 1
+        continue
+      }
+      const installation = state.installations.find(current => current.userId === item.userId
+        && current.installationId === item.installationId
+        && current.clientAccountId === item.clientAccountId)
+      if (installation && !this.registrationIsAuthorized(installation)) {
+        this.store.mutate(current => {
+          current.installations = current.installations.filter(entry => !(entry.userId === item.userId
+            && entry.installationId === item.installationId
+            && entry.clientAccountId === item.clientAccountId))
+          current.outbox = current.outbox.filter(entry => !(entry.userId === item.userId
+            && entry.installationId === item.installationId
+            && entry.clientAccountId === item.clientAccountId))
+        })
+        summary.failed += 1
+        continue
+      }
+      const subscribed = !item.roomId || state.groupSubscriptions.some(subscription => subscription.userId === item.userId
+        && subscription.roomId === item.roomId
+        && subscription.enabled)
+      if (!installation || !subscribed || item.expiresAt <= this.now()) {
+        this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
+        summary.failed += 1
+        continue
+      }
+      const payload: Record<string, unknown> = {
+        ...item.data,
+        aps: {
+          alert: { title: item.title, body: item.body },
+          sound: 'default',
+          badge: installation.badge,
+          ...(item.threadId ? { 'thread-id': item.threadId } : {}),
+        },
+        version: 1,
+        eventId: item.eventId,
+        kind: item.kind,
+        clientAccountId: item.clientAccountId,
+      }
+      const result = await provider.send({
+        deviceToken: installation.deviceToken,
+        environment: installation.environment,
+        payload,
+        apnsId: item.id,
+        ...(item.collapseId ? { collapseId: item.collapseId } : {}),
+        expiration: Math.floor(item.expiresAt / 1_000),
+      })
+      const at = new Date(this.now()).toISOString()
+      if (result.disposition === 'success') {
+        this.store.mutate(current => {
+          current.outbox = current.outbox.filter(entry => entry.id !== item.id)
+          current.status.lastSuccessAt = at
+        })
+        summary.delivered += 1
+      } else if (result.disposition === 'unregister') {
+        let registrationRotated = false
+        this.store.mutate(current => {
+          const latest = current.installations.find(entry => entry.userId === item.userId
+            && entry.installationId === item.installationId
+            && entry.clientAccountId === item.clientAccountId)
+          registrationRotated = Boolean(latest && (
+            latest.deviceToken !== installation.deviceToken
+              || latest.environment !== installation.environment
+          ))
+          if (registrationRotated) {
+            const pending = current.outbox.find(entry => entry.id === item.id)
+            if (pending) {
+              pending.attempts = 0
+              pending.nextAttemptAt = this.now()
+            }
+            return
+          }
+          current.installations = current.installations.filter(entry => !(entry.userId === item.userId
+            && entry.installationId === item.installationId
+            && entry.clientAccountId === item.clientAccountId))
+          current.outbox = current.outbox.filter(entry => !(entry.userId === item.userId
+            && entry.installationId === item.installationId
+            && entry.clientAccountId === item.clientAccountId))
+        })
+        if (registrationRotated) summary.retried += 1
+        else summary.removedRegistrations += 1
+      } else if (result.disposition === 'retry' || result.disposition === 'configuration') {
+        const attempts = item.attempts + 1
+        const exhausted = attempts >= this.maxAttempts || item.expiresAt <= this.now()
+        const delay = Math.min(
+          this.maxRetryMilliseconds,
+          Math.max(result.retryAfterMs ?? 0, this.baseRetryMilliseconds * (2 ** Math.max(0, attempts - 1))),
+        )
+        this.store.mutate(current => {
+          const pending = current.outbox.find(entry => entry.id === item.id)
+          if (pending) {
+            if (exhausted) current.outbox = current.outbox.filter(entry => entry.id !== item.id)
+            else {
+              pending.attempts = attempts
+              pending.nextAttemptAt = this.now() + delay
+            }
+          }
+          current.status.lastError = safeError(result)
+          current.status.lastErrorAt = at
+        })
+        if (exhausted) summary.failed += 1
+        else summary.retried += 1
+      } else {
+        this.store.mutate(current => {
+          current.outbox = current.outbox.filter(entry => entry.id !== item.id)
+          current.status.lastError = safeError(result)
+          current.status.lastErrorAt = at
+        })
+        summary.failed += 1
+      }
+    }
+    return summary
+  }
+
+  private scheduleNextFlush(): void {
+    if (!this.autoFlush || this.closed || !this.provider || !this.capabilities().enabled || this.activeFlush) return
+    if (this.timer) clearTimeout(this.timer)
+    this.timer = undefined
+    const next = this.store.snapshot().outbox.reduce<number | undefined>((earliest, item) => (
+      earliest === undefined || item.nextAttemptAt < earliest ? item.nextAttemptAt : earliest
+    ), undefined)
+    if (next === undefined) return
+    const scheduledAt = Math.max(next, this.flushBlockedUntil)
+    const delay = Math.max(0, Math.min(2_147_483_647, scheduledAt - this.now()))
+    this.timer = setTimeout(() => {
+      this.timer = undefined
+      void this.flushDue().catch(() => undefined)
+    }, delay)
+    this.timer.unref()
+  }
+
+  close(): void {
+    this.closed = true
+    if (this.timer) clearTimeout(this.timer)
+    this.timer = undefined
+    this.provider?.close?.()
+  }
+
+  private recordRuntimeError(cause: unknown): void {
+    const raw = cause instanceof Error ? cause.message : String(cause)
+    const message = raw
+      .replace(/[a-fA-F0-9]{32,}/g, '[redacted]')
+      .replace(/[\r\n]+/g, ' ')
+      .slice(0, 500) || '推送后台任务失败'
+    const at = new Date(this.now()).toISOString()
+    this.runtimeError = { message, at }
+    try {
+      this.store.mutate(state => {
+        state.status.lastError = message
+        state.status.lastErrorAt = at
+      })
+    } catch {
+      // The in-memory status remains available when the push state volume is
+      // the reason persistence failed. Never let optional push stop 8800.
+    }
+  }
+
+  private registrationIsAuthorized(installation: InstallationRecord): boolean {
+    if (!this.userAuthorizationVersion) return true
+    try {
+      const current = this.userAuthorizationVersion(installation.userId)
+      return current !== undefined && installation.authorizationVersion === current
+    } catch {
+      return false
+    }
+  }
+
+  private markPersistedJobsDisconnected(): void {
+    try {
+      const jobs = this.store.snapshot().chatJobs
+      if (!jobs.some(job => !Number.isFinite(Number(job.metadata?.disconnectedAt)))) return
+      const disconnectedAt = this.now()
+      this.store.mutate(state => {
+        state.chatJobs = state.chatJobs.map(job => ({
+          ...job,
+          metadata: {
+            ...job.metadata,
+            disconnectedAt: Number.isFinite(Number(job.metadata?.disconnectedAt))
+              ? job.metadata!.disconnectedAt
+              : disconnectedAt,
+            recoveredAfterRestart: true,
+          },
+        }))
+      })
+    } catch {
+      // A corrupt/unwritable optional push state already disables recovery.
+    }
+  }
+
+  private loadCorrelationSecret(): Buffer {
+    if (this.correlationSecret) return this.correlationSecret
+    const path = join(this.options.home, 'push', 'correlation.key')
+    try {
+      const existing = readFileSync(path)
+      if (existing.byteLength !== 32) throw new Error('Push correlation key has an invalid length')
+      chmodSync(path, 0o600)
+      this.correlationSecret = existing
+      return existing
+    } catch (cause) {
+      if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause
+    }
+    mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+    const generated = randomBytes(32)
+    let descriptor: number | undefined
+    try {
+      descriptor = openSync(path, 'wx', 0o600)
+      writeFileSync(descriptor, generated)
+      fsyncSync(descriptor)
+      closeSync(descriptor)
+      descriptor = undefined
+      this.correlationSecret = generated
+      return generated
+    } catch (cause) {
+      if (descriptor !== undefined) closeSync(descriptor)
+      if ((cause as NodeJS.ErrnoException).code === 'EEXIST') {
+        const existing = readFileSync(path)
+        if (existing.byteLength !== 32) throw new Error('Push correlation key has an invalid length')
+        this.correlationSecret = existing
+        return existing
+      }
+      throw cause
+    }
+  }
+}

+ 316 - 0
src/server/pushEventAdapter.ts

@@ -0,0 +1,316 @@
+import { createHash } from 'node:crypto'
+import type { UpstreamServiceSession } from './localAuth.js'
+import {
+  type ChatPushJob,
+  type ChatPushObservation,
+  type GroupWatchAnchor,
+  type GroupWatchReset,
+  type PushEventCoordinator,
+  type PushNotificationCandidate,
+} from './pushEvents.js'
+import { PushCoordinator } from './pushCoordinator.js'
+
+type JsonRecord = Record<string, unknown>
+
+function record(value: unknown): JsonRecord {
+  return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
+}
+
+function values(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : []
+}
+
+function text(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+function number(value: unknown): number | undefined {
+  const result = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN
+  return Number.isFinite(result) ? result : undefined
+}
+
+function timestampMilliseconds(value: unknown): number | undefined {
+  const numeric = number(value)
+  if (numeric !== undefined) return numeric < 10_000_000_000 ? numeric * 1_000 : numeric
+  if (typeof value !== 'string') return undefined
+  const parsed = Date.parse(value)
+  return Number.isNaN(parsed) ? undefined : parsed
+}
+
+function summary(value: unknown, fallback: string): string {
+  const normalized = text(value).replace(/\s+/g, ' ')
+  if (!normalized) return fallback
+  return Array.from(normalized).length > 180 ? `${Array.from(normalized).slice(0, 179).join('')}…` : normalized
+}
+
+function collapseID(prefix: string, value: string): string {
+  return `${prefix}:${createHash('sha256').update(value, 'utf8').digest('base64url').slice(0, 32)}`
+}
+
+function parseResponse(body: Buffer): JsonRecord {
+  try { return record(JSON.parse(body.toString('utf8'))) } catch { throw new Error('Hermes returned invalid group reconciliation JSON') }
+}
+
+interface ReconciliationSubscription {
+  userId: string
+  roomId: string
+  startedAt: number
+  lastMessageSeq?: number
+}
+
+/**
+ * Adapts the durable push store to the realtime observers. Cursor resets first
+ * reconcile every active room from REST, then move to the server-provided
+ * latest anchor. Existing processed event IDs make the replay idempotent.
+ */
+export class PushCoordinatorEventAdapter implements PushEventCoordinator {
+  constructor(
+    readonly push: PushCoordinator,
+    readonly upstreamSession: UpstreamServiceSession,
+  ) {}
+
+  observeChat(_observation: ChatPushObservation): void {}
+  saveChatJob(job: ChatPushJob): void { this.push.saveChatJob(job) }
+  completeChatJob(jobID: string): void { this.push.completeChatJob(jobID) }
+  pendingChatJobs(): readonly ChatPushJob[] { return this.push.pendingChatJobs() }
+  promptDigest(localUserID: string, prompt: string): string {
+    return this.push.promptDigest(localUserID, prompt)
+  }
+  canRecoverChatJob(job: ChatPushJob): boolean { return this.push.chatJobRecoveryAllowed(job) }
+  enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
+    if ('roomID' in candidate) {
+      if (candidate.messageSequence !== undefined) {
+        const lastSequence = this.push.groupSubscriptionLastMessageSeq(candidate.localUserID, candidate.roomID)
+        if (lastSequence !== undefined && candidate.messageSequence <= lastSequence) return 'ignored'
+        const result = this.push.enqueueNotification(candidate)
+        this.push.advanceGroupSubscriptionMessageSeq(
+          candidate.localUserID,
+          candidate.roomID,
+          candidate.messageSequence,
+        )
+        return result
+      } else {
+        const startedAt = this.push.groupSubscriptionStartedAt(candidate.localUserID, candidate.roomID)
+        if (startedAt === undefined || candidate.occurredAt === undefined || candidate.occurredAt + 2_000 < startedAt) {
+          return 'ignored'
+        }
+      }
+    }
+    return this.push.enqueueNotification(candidate)
+  }
+  groupWatchAnchor(): GroupWatchAnchor | undefined {
+    const anchor = this.push.groupWatchAnchor()
+    return anchor ? { epoch: anchor.epoch, cursor: anchor.cursor } : undefined
+  }
+  groupSubscribers(roomID: string): readonly string[] { return this.push.groupSubscribers(roomID) }
+  advanceGroupCursor(anchor: GroupWatchAnchor): void { this.push.advanceGroupCursor(anchor) }
+
+  async resetGroupCursor(reset: GroupWatchReset): Promise<GroupWatchAnchor> {
+    if (reset.reason !== 'initial') await this.reconcileGroups()
+    const anchor = this.push.resetGroupCursor(reset)
+    return { epoch: anchor.epoch, cursor: anchor.cursor }
+  }
+
+  async reconcileGroups(): Promise<void> {
+    const subscriptions = this.push.activeGroupSubscriptions().flatMap(item => {
+      const startedAt = Date.parse(item.updatedAt)
+      return Number.isNaN(startedAt) ? [] : [{
+        userId: item.userId,
+        roomId: item.roomId,
+        startedAt,
+        ...(item.lastMessageSeq !== undefined ? { lastMessageSeq: item.lastMessageSeq } : {}),
+      }]
+    })
+    const byRoom = new Map<string, ReconciliationSubscription[]>()
+    for (const subscription of subscriptions) {
+      byRoom.set(subscription.roomId, [...(byRoom.get(subscription.roomId) ?? []), subscription])
+    }
+    for (const [roomID, roomSubscriptions] of byRoom) {
+      await this.reconcileRoom(roomID, roomSubscriptions)
+    }
+  }
+
+  private async reconcileRoom(roomID: string, subscriptions: ReconciliationSubscription[]): Promise<void> {
+    const detailResponse = await this.upstreamSession.request(
+      `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}`,
+    )
+    if (detailResponse.status < 200 || detailResponse.status >= 300) {
+      if (detailResponse.status === 404) {
+        for (const subscription of subscriptions) {
+          this.push.setGroupSubscription(subscription.userId, roomID, false)
+        }
+        return
+      }
+      throw new Error(`Unable to reconcile group ${roomID} (${detailResponse.status})`)
+    }
+    const detailPayload = parseResponse(detailResponse.body)
+    const detailData = record(detailPayload.data)
+    const room = record(detailPayload.room ?? detailData.room ?? detailPayload.data ?? detailPayload)
+    const roomName = text(room.name) || '团队'
+    await this.reconcileMessages(roomID, roomName, subscriptions)
+    await this.reconcileInteractions(roomID, roomName, subscriptions, room)
+  }
+
+  private async reconcileMessages(
+    roomID: string,
+    roomName: string,
+    subscriptions: ReconciliationSubscription[],
+  ): Promise<void> {
+    if (subscriptions.some(item => item.lastMessageSeq === undefined)) {
+      const baselineResponse = await this.upstreamSession.request(
+        `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}/messages`,
+        { search: new URLSearchParams({ limit: '1' }) },
+      )
+      if (baselineResponse.status < 200 || baselineResponse.status >= 300) {
+        throw new Error(`Unable to establish group baseline for ${roomID} (${baselineResponse.status})`)
+      }
+      const baselinePayload = parseResponse(baselineResponse.body)
+      const baselineData = record(baselinePayload.data)
+      const baseline = values(
+        baselinePayload.items ?? baselinePayload.messages ?? baselineData.items ?? baselineData.messages,
+      ).map(record).reduce((maximum, message) => {
+        const sequence = number(message.seq ?? message.sequence)
+        return sequence !== undefined && Number.isSafeInteger(sequence) ? Math.max(maximum, sequence) : maximum
+      }, 0)
+      for (const subscription of subscriptions) {
+        if (subscription.lastMessageSeq !== undefined) continue
+        this.push.advanceGroupSubscriptionMessageSeq(subscription.userId, roomID, baseline)
+        subscription.lastMessageSeq = baseline
+      }
+    }
+    let afterSequence = Math.min(...subscriptions.map(item => item.lastMessageSeq ?? 0))
+    if (afterSequence === 0) {
+      const collected: JsonRecord[] = []
+      let beforeSequence: number | undefined
+      let complete = false
+      for (let page = 0; page < 200; page += 1) {
+        const search = new URLSearchParams({ limit: '100' })
+        if (beforeSequence !== undefined) search.set('beforeSeq', String(beforeSequence))
+        const messages = await this.fetchGroupMessages(roomID, search)
+        if (!messages.length) { complete = true; break }
+        collected.push(...messages)
+        const sequences = messages
+          .map(message => number(message.seq ?? message.sequence))
+          .filter((value): value is number => value !== undefined && Number.isSafeInteger(value))
+        if (messages.length < 100 || !sequences.length) { complete = true; break }
+        const nextBefore = Math.min(...sequences)
+        if (beforeSequence !== undefined && nextBefore >= beforeSequence) break
+        beforeSequence = nextBefore
+      }
+      if (!complete) throw new Error(`Group reconciliation exceeded the safe page limit for ${roomID}`)
+      const ordered = [...new Map(collected.map(message => [String(message.id ?? message.seq), message])).values()]
+        .sort((left, right) => Number(left.seq ?? left.sequence) - Number(right.seq ?? right.sequence))
+      this.processGroupMessages(roomID, roomName, subscriptions, ordered, 0)
+      return
+    }
+    for (let page = 0; page < 50; page += 1) {
+      const search = new URLSearchParams({ limit: '100' })
+      search.set('afterSeq', String(afterSequence))
+      const messages = await this.fetchGroupMessages(roomID, search)
+      if (!messages.length) return
+      const greatestSequence = this.processGroupMessages(
+        roomID, roomName, subscriptions, messages, afterSequence,
+      )
+      if (messages.length < 100 || greatestSequence <= afterSequence) return
+      afterSequence = greatestSequence
+    }
+    throw new Error(`Group reconciliation exceeded the safe page limit for ${roomID}`)
+  }
+
+  private async fetchGroupMessages(roomID: string, search: URLSearchParams): Promise<JsonRecord[]> {
+    const response = await this.upstreamSession.request(
+      `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}/messages`,
+      { search },
+    )
+    if (response.status < 200 || response.status >= 300) {
+      throw new Error(`Unable to reconcile group messages for ${roomID} (${response.status})`)
+    }
+    const payload = parseResponse(response.body)
+    const data = record(payload.data)
+    return values(payload.items ?? payload.messages ?? data.items ?? data.messages).map(record)
+  }
+
+  private processGroupMessages(
+    roomID: string,
+    roomName: string,
+    subscriptions: ReconciliationSubscription[],
+    messages: JsonRecord[],
+    afterSequence: number,
+  ): number {
+    let greatestSequence = afterSequence
+    for (const message of messages) {
+      const sequence = number(message.seq ?? message.sequence)
+      const updatedAt = timestampMilliseconds(message.updatedAt ?? message.updated_at ?? message.createdAt ?? message.created_at)
+      if (sequence === undefined || !Number.isSafeInteger(sequence) || sequence <= afterSequence) continue
+      greatestSequence = Math.max(greatestSequence, sequence)
+      const senderKind = text(message.senderKind ?? message.sender_kind).toLowerCase()
+      const status = text(message.status).toLowerCase()
+      const messageID = text(message.id)
+      if (senderKind !== 'agent' || !messageID || (status !== 'completed' && status !== 'failed')) continue
+      for (const subscription of subscriptions) {
+        if (sequence <= (subscription.lastMessageSeq ?? 0)) continue
+        const kind = status === 'completed' ? 'group.message.completed' as const : 'group.message.failed' as const
+        const eventID = `group-message:${messageID}:${status}`
+        const sender = text(message.senderName ?? message.sender_name) || 'Agent'
+        const topicID = text(message.topicId ?? message.topic_id)
+        this.enqueueNotification({
+          eventID,
+          localUserID: subscription.userId,
+          kind,
+          title: `${roomName} · ${sender}`,
+          body: summary(message.error ?? message.content, status === 'completed' ? '打开夭夭查看团队消息' : '打开夭夭查看失败详情'),
+          collapseID: collapseID('group-message', messageID),
+          roomID,
+          messageSequence: sequence,
+          occurredAt: updatedAt,
+          ...(topicID ? { topicID } : {}),
+          data: {
+            version: '1', eventId: eventID, kind, roomId: roomID,
+            ...(topicID ? { topicId: topicID } : {}), messageId: messageID,
+          },
+        })
+      }
+    }
+    return greatestSequence
+  }
+
+  private async reconcileInteractions(
+    roomID: string,
+    roomName: string,
+    subscriptions: ReconciliationSubscription[],
+    room: JsonRecord,
+  ): Promise<void> {
+    const interactions = values(room.pendingInteractions ?? room.pending_interactions)
+      .map(record)
+    for (const interaction of interactions) {
+      const status = text(interaction.status).toLowerCase()
+      const interactionID = text(interaction.id)
+      const updatedAt = timestampMilliseconds(
+        interaction.updatedAt ?? interaction.updated_at ?? interaction.createdAt ?? interaction.created_at,
+      )
+      if (!interactionID || status && status !== 'pending' || updatedAt === undefined) continue
+      const interactionKind = text(interaction.kind).toLowerCase()
+      const clarification = interactionKind === 'clarification'
+      const payload = record(interaction.payload)
+      const topicID = text(interaction.topicId ?? interaction.topic_id)
+      const eventID = `group-interaction:${interactionID}`
+      for (const subscription of subscriptions) {
+        if (updatedAt + 2_000 < subscription.startedAt) continue
+        this.push.enqueueNotification({
+          eventID,
+          localUserID: subscription.userId,
+          kind: 'group.interaction.requested',
+          title: clarification ? `${roomName} · 需要补充信息` : `${roomName} · 请求审批`,
+          body: summary(payload.question ?? payload.message ?? payload.prompt, '请打开夭夭继续处理'),
+          collapseID: collapseID('group-interaction', interactionID),
+          roomID,
+          ...(topicID ? { topicID } : {}),
+          data: {
+            version: '1', eventId: eventID, kind: 'group.interaction.requested', roomId: roomID,
+            ...(topicID ? { topicId: topicID } : {}), interactionId: interactionID,
+          },
+        })
+      }
+    }
+  }
+}

+ 1652 - 0
src/server/pushEvents.ts

@@ -0,0 +1,1652 @@
+import { createHash, randomUUID } from 'node:crypto'
+import WebSocket, { type RawData } from 'ws'
+import { isSupportedGroupProtocolVersion } from '../shared/types.js'
+import type { ServerConfig } from './config.js'
+import type { UpstreamServiceSession } from './localAuth.js'
+import type { PushCoordinator as PersistentPushCoordinator } from './pushCoordinator.js'
+
+type MaybePromise<T> = T | Promise<T>
+type JsonRecord = Record<string, unknown>
+
+export interface ChatRelayIdentity {
+  localUserID: string
+  connectionID?: string
+  accountKey?: string
+  source: 'web' | 'gateway' | 'paired'
+}
+
+interface ChatObservationBase {
+  localUserID: string
+  connectionID: string
+  accountKey?: string
+  source: ChatRelayIdentity['source']
+  observedAt: number
+}
+
+export type ChatPushObservation = ChatObservationBase & (
+  | {
+    type: 'chat.session_opened'
+    action: 'create' | 'resume'
+    profile: string
+    runtimeSessionID: string
+    storedSessionID: string
+  }
+  | {
+    type: 'chat.prompt'
+    phase: 'submitted' | 'accepted' | 'rejected'
+    requestID: string
+    profile?: string
+    runtimeSessionID: string
+    storedSessionID?: string
+    queued: boolean
+    status?: string
+    error?: string
+  }
+  | {
+    type: 'chat.rpc_event'
+    eventType: string
+    profile?: string
+    runtimeSessionID?: string
+    storedSessionID?: string
+    payload: JsonRecord
+  }
+  | {
+    type: 'chat.disconnected'
+    activeSessions: Array<{
+      profile: string
+      runtimeSessionID: string
+      storedSessionID: string
+    }>
+  }
+)
+
+type ChatObservationPayload = ChatPushObservation extends infer Observation
+  ? Observation extends ChatObservationBase
+    ? Omit<Observation, keyof ChatObservationBase>
+    : never
+  : never
+
+export interface GroupWatchAnchor {
+  epoch: string
+  cursor: number
+}
+
+export interface GroupWatchReset extends GroupWatchAnchor {
+  reason: string
+}
+
+export interface ChatPushJob {
+  id: string
+  localUserID: string
+  profile?: string
+  runtimeSessionID: string
+  storedSessionID?: string
+  requestID: string
+  queued: boolean
+  phase: 'submitted' | 'accepted' | 'watching'
+  submittedAt: number
+  expiresAt: number
+  metadata?: Record<string, unknown>
+}
+
+interface NotificationCandidateBase {
+  eventID: string
+  localUserID: string
+  title: string
+  body: string
+  collapseID: string
+  data: Record<string, string>
+}
+
+export interface ChatPushCandidate extends NotificationCandidateBase {
+  kind: 'chat.completed' | 'chat.failed' | 'chat.approval.requested' | 'chat.clarification.requested'
+  jobID: string
+  profile?: string
+  sessionID?: string
+  requestID?: string
+}
+
+export interface GroupPushCandidate {
+  eventID: NotificationCandidateBase['eventID']
+  localUserID: NotificationCandidateBase['localUserID']
+  title: NotificationCandidateBase['title']
+  body: NotificationCandidateBase['body']
+  collapseID: NotificationCandidateBase['collapseID']
+  data: NotificationCandidateBase['data']
+  kind: 'group.message.completed' | 'group.message.failed' | 'group.interaction.requested'
+  epoch?: string
+  cursor?: number
+  roomID: string
+  topicID?: string
+  messageID?: string
+  messageSequence?: number
+  interactionID?: string
+  interactionKind?: 'approval' | 'clarification' | 'unknown'
+  senderName?: string
+  content?: string
+  error?: string
+  occurredAt?: number
+}
+
+export type PushNotificationCandidate = ChatPushCandidate | GroupPushCandidate
+
+/**
+ * Persistence and delivery boundary used by the relay and the group watcher.
+ * Implementations must durably deduplicate `enqueueNotification` by
+ * `localUserID + eventID`. `advanceGroupCursor` is called only after every
+ * candidate for that cursor has been durably accepted by the outbox.
+ */
+export interface PushEventCoordinator {
+  observeChat?(observation: ChatPushObservation): MaybePromise<void>
+  saveChatJob(job: ChatPushJob): MaybePromise<void>
+  completeChatJob(jobID: string): MaybePromise<void>
+  pendingChatJobs(): MaybePromise<readonly ChatPushJob[]>
+  recoverChatJob?(job: ChatPushJob): MaybePromise<void>
+  promptDigest?(localUserID: string, prompt: string): MaybePromise<string>
+  canRecoverChatJob?(job: ChatPushJob): MaybePromise<boolean>
+  enqueueNotification(candidate: PushNotificationCandidate): MaybePromise<'enqueued' | 'duplicate' | 'ignored' | void>
+  groupWatchAnchor(): MaybePromise<GroupWatchAnchor | undefined>
+  groupSubscribers(roomID: string): MaybePromise<readonly string[]>
+  advanceGroupCursor(anchor: GroupWatchAnchor): MaybePromise<void>
+  resetGroupCursor(reset: GroupWatchReset): MaybePromise<GroupWatchAnchor | void>
+}
+
+/** Keep the event pipeline decoupled from the concrete persistent coordinator. */
+export function createPushEventCoordinator(push: PersistentPushCoordinator): PushEventCoordinator {
+  return {
+    saveChatJob: job => { push.saveChatJob(job) },
+    completeChatJob: jobID => { push.completeChatJob(jobID) },
+    pendingChatJobs: () => push.pendingChatJobs(),
+    promptDigest: (localUserID, prompt) => push.promptDigest(localUserID, prompt),
+    canRecoverChatJob: job => push.chatJobRecoveryAllowed(job),
+    enqueueNotification: candidate => push.enqueueNotification(candidate),
+    groupWatchAnchor: () => push.groupWatchAnchor(),
+    groupSubscribers: roomID => push.subscribedUserIds(roomID),
+    advanceGroupCursor: anchor => { push.advanceGroupCursor(anchor) },
+    resetGroupCursor: reset => push.resetGroupCursor(reset),
+  }
+}
+
+function record(value: unknown): JsonRecord {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? value as JsonRecord
+    : {}
+}
+
+function string(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+function requestID(value: unknown): string {
+  return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
+}
+
+function errorMessage(value: unknown): string {
+  const source = record(value)
+  return string(source.message ?? source.error) || (value == null ? '' : String(value))
+}
+
+function summary(value: unknown, fallback: string): string {
+  const normalized = string(value).replace(/\s+/g, ' ')
+  if (!normalized) return fallback
+  return normalized.length > 180 ? `${normalized.slice(0, 179)}…` : normalized
+}
+
+function eventTimestamp(value: unknown): number | undefined {
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return value < 10_000_000_000 ? value * 1_000 : value
+  }
+  if (typeof value !== 'string' || !value.trim()) return undefined
+  const numeric = Number(value)
+  if (Number.isFinite(numeric)) return numeric < 10_000_000_000 ? numeric * 1_000 : numeric
+  const parsed = Date.parse(value)
+  return Number.isNaN(parsed) ? undefined : parsed
+}
+
+function collapseID(prefix: string, value: string): string {
+  return `${prefix}:${createHash('sha256').update(value, 'utf8').digest('base64url').slice(0, 32)}`
+}
+
+function notificationData(
+  eventID: string,
+  kind: PushNotificationCandidate['kind'],
+  localUserID: string,
+  values: Record<string, string | undefined>,
+): Record<string, string> {
+  return Object.fromEntries(Object.entries({
+    version: '1', eventId: eventID, kind, clientAccountId: localUserID, ...values,
+  }).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && Boolean(entry[1])))
+}
+
+interface SessionIdentity {
+  profile: string
+  runtimeSessionID: string
+  storedSessionID: string
+  baseline?: ChatMessageBaseline
+  baselinePromise?: Promise<ChatMessageBaseline>
+}
+
+interface PendingSessionOpen {
+  action: 'create' | 'resume'
+  profile: string
+  requestedStoredSessionID: string
+}
+
+interface PendingPrompt {
+  requestID: string
+  runtimeSessionID: string
+  storedSessionID?: string
+  profile?: string
+  queued: boolean
+  job: ChatPushJob
+}
+
+export interface ChatTerminalContent {
+  title?: string
+  body?: string
+  messageID?: string
+  timestamp?: number
+  confirmed?: boolean
+  failed?: boolean
+  error?: string
+  correlated?: boolean
+}
+
+export interface ChatMessageBaseline {
+  messageID?: string
+  sequence?: number
+  total: number
+  assistantCount: number
+  lastRowSequence?: number
+}
+
+export interface ChatNotificationResolver {
+  captureBaseline?(job: ChatPushJob): Promise<ChatMessageBaseline>
+  resolveTerminal(job: ChatPushJob, eventType: string, payload: JsonRecord): Promise<ChatTerminalContent>
+}
+
+/** Observes one already-authorized chat relay without changing its protocol. */
+export class ChatPushRelayObserver {
+  readonly #connectionID: string
+  readonly #sessions = new Map<string, SessionIdentity>()
+  readonly #pendingSessionOpens = new Map<string, PendingSessionOpen>()
+  readonly #pendingPrompts = new Map<string, PendingPrompt>()
+  readonly #jobsByRuntimeSession = new Map<string, ChatPushJob[]>()
+  readonly #terminalJobIDs = new Set<string>()
+  #closed = false
+  #delivery = Promise.resolve()
+
+  constructor(
+    readonly coordinator: PushEventCoordinator,
+    readonly identity: ChatRelayIdentity,
+    readonly now: () => number = Date.now,
+    readonly notificationResolver?: ChatNotificationResolver,
+  ) {
+    this.#connectionID = identity.connectionID ?? randomUUID().toLowerCase()
+  }
+
+  observeClientFrame(frameText: string): void {
+    if (this.#closed) return
+    let frame: JsonRecord
+    try { frame = record(JSON.parse(frameText)) } catch { return }
+    const method = string(frame.method)
+    const id = requestID(frame.id)
+    const params = record(frame.params)
+    if ((method === 'session.create' || method === 'session.resume') && id) {
+      this.#pendingSessionOpens.set(id, {
+        action: method === 'session.create' ? 'create' : 'resume',
+        profile: string(params.profile),
+        requestedStoredSessionID: method === 'session.resume' ? string(params.session_id) : '',
+      })
+      return
+    }
+    if (method !== 'prompt.submit' || !id) return
+    const runtimeSessionID = string(params.session_id)
+    if (!runtimeSessionID) return
+    const session = this.#sessions.get(runtimeSessionID)
+    let job: ChatPushJob = {
+      id: randomUUID().toLowerCase(),
+      localUserID: this.identity.localUserID,
+      profile: session?.profile,
+      runtimeSessionID,
+      storedSessionID: session?.storedSessionID,
+      requestID: id,
+      queued: params.queued === true,
+      phase: 'submitted',
+      submittedAt: this.now(),
+      expiresAt: this.now() + 24 * 60 * 60 * 1_000,
+    }
+    if (this.coordinator.promptDigest) {
+      try {
+        const digest = this.coordinator.promptDigest(
+          this.identity.localUserID,
+          string(params.text),
+        )
+        if (typeof digest === 'string' && digest) {
+          job = { ...job, metadata: { ...job.metadata, promptDigest: digest } }
+        }
+      } catch { /* Missing correlation fails closed without blocking chat. */ }
+    }
+    if (session?.baseline) {
+      const baseline = session.baseline
+      job = {
+        ...job,
+        metadata: {
+          ...job.metadata,
+          baselineCaptured: true,
+          baselineMessageID: baseline.messageID,
+          baselineSequence: baseline.sequence,
+          baselineTotal: baseline.total,
+          baselineAssistantCount: baseline.assistantCount,
+          baselineRowSequence: baseline.lastRowSequence,
+        },
+      }
+    } else {
+      job = { ...job, metadata: { ...job.metadata, baselineCaptured: false } }
+    }
+    const prompt: PendingPrompt = {
+      requestID: id,
+      runtimeSessionID,
+      storedSessionID: session?.storedSessionID,
+      profile: session?.profile,
+      queued: params.queued === true,
+      job,
+    }
+    this.#pendingPrompts.set(id, prompt)
+    this.#jobsByRuntimeSession.set(runtimeSessionID, [
+      ...(this.#jobsByRuntimeSession.get(runtimeSessionID) ?? []),
+      prompt.job,
+    ])
+    this.#enqueue(() => this.coordinator.saveChatJob(prompt.job))
+    if (!session?.baseline && session?.baselinePromise) {
+      void session.baselinePromise.then(baseline => {
+        this.#applyBaseline(runtimeSessionID, prompt.job.id, baseline)
+      }).catch(() => undefined)
+    }
+    this.#emit({
+      type: 'chat.prompt',
+      phase: 'submitted',
+      requestID: prompt.requestID,
+      runtimeSessionID: prompt.runtimeSessionID,
+      storedSessionID: prompt.storedSessionID,
+      profile: prompt.profile,
+      queued: prompt.queued,
+    })
+  }
+
+  observeUpstreamFrame(data: RawData, isBinary: boolean): void {
+    if (this.#closed || isBinary) return
+    let frame: JsonRecord
+    try { frame = record(JSON.parse(Buffer.from(data as Uint8Array).toString('utf8'))) } catch { return }
+    const id = requestID(frame.id)
+    if (id) {
+      const pendingOpen = this.#pendingSessionOpens.get(id)
+      if (pendingOpen) {
+        this.#pendingSessionOpens.delete(id)
+        if (!frame.error) {
+          const result = record(frame.result)
+          const runtimeSessionID = string(result.session_id ?? result.sessionId)
+          const storedSessionID = string(
+            result.stored_session_id ?? result.storedSessionId ?? result.session_key,
+          ) || pendingOpen.requestedStoredSessionID
+          if (runtimeSessionID && storedSessionID && pendingOpen.profile) {
+            const session: SessionIdentity = {
+              profile: pendingOpen.profile,
+              runtimeSessionID,
+              storedSessionID,
+              ...(pendingOpen.action === 'create'
+                ? { baseline: { total: 0, assistantCount: 0, lastRowSequence: 0 } }
+                : {}),
+            }
+            this.#sessions.set(runtimeSessionID, session)
+            if (pendingOpen.action === 'resume' && this.notificationResolver?.captureBaseline) {
+              const baselineJob: ChatPushJob = {
+                id: `baseline:${this.#connectionID}:${runtimeSessionID}`,
+                localUserID: this.identity.localUserID,
+                profile: session.profile,
+                runtimeSessionID,
+                storedSessionID,
+                requestID: 'baseline',
+                queued: false,
+                phase: 'submitted',
+                submittedAt: this.now(),
+                expiresAt: this.now() + 60_000,
+              }
+              const baselinePromise = this.notificationResolver.captureBaseline(baselineJob)
+              session.baselinePromise = baselinePromise
+              void baselinePromise.then(baseline => {
+                if (this.#sessions.get(runtimeSessionID) === session) session.baseline = baseline
+              }).catch(() => undefined)
+            }
+            this.#emit({
+              type: 'chat.session_opened',
+              profile: session.profile,
+              runtimeSessionID,
+              storedSessionID,
+              action: pendingOpen.action,
+            })
+          }
+        }
+      }
+      const pendingPrompt = this.#pendingPrompts.get(id)
+      if (pendingPrompt) {
+        this.#pendingPrompts.delete(id)
+        const result = record(frame.result)
+        const status = string(result.status) || (frame.error ? 'rejected' : 'accepted')
+        this.#emit({
+          type: 'chat.prompt',
+          phase: frame.error ? 'rejected' : 'accepted',
+          requestID: pendingPrompt.requestID,
+          runtimeSessionID: pendingPrompt.runtimeSessionID,
+          storedSessionID: pendingPrompt.storedSessionID,
+          profile: pendingPrompt.profile,
+          queued: pendingPrompt.queued,
+          status,
+          ...(frame.error ? { error: errorMessage(frame.error) || 'Hermes rejected the prompt' } : {}),
+        })
+        if (this.#terminalJobIDs.has(pendingPrompt.job.id)) {
+          // A terminal event can legally overtake its JSON-RPC receipt.
+          // Never recreate a job that is already being completed.
+        } else if (frame.error) {
+          this.#removeJob(pendingPrompt.job)
+          this.#enqueue(() => this.coordinator.completeChatJob(pendingPrompt.job.id))
+        } else {
+          const runID = string(result.run_id ?? result.runId ?? result.turn_id ?? result.turnId)
+          const accepted: ChatPushJob = {
+            ...pendingPrompt.job,
+            phase: 'accepted',
+            ...(runID ? { metadata: { ...pendingPrompt.job.metadata, runID } } : {}),
+          }
+          this.#replaceJob(accepted)
+          this.#enqueue(() => this.coordinator.saveChatJob(accepted))
+        }
+      }
+    }
+    if (string(frame.method) !== 'event') return
+    const params = record(frame.params)
+    const eventType = string(params.type)
+    if (!eventType || eventType === 'gateway.ready') return
+    const runtimeSessionID = string(params.session_id)
+    const session = runtimeSessionID ? this.#sessions.get(runtimeSessionID) : undefined
+    this.#emit({
+      type: 'chat.rpc_event',
+      eventType,
+      profile: string(params.profile) || session?.profile || undefined,
+      runtimeSessionID: runtimeSessionID || undefined,
+      storedSessionID: session?.storedSessionID,
+      payload: record(params.payload),
+    })
+    if (runtimeSessionID) this.#observeRPCEvent(
+      eventType,
+      runtimeSessionID,
+      record(params.payload),
+    )
+  }
+
+  disconnected(): void {
+    if (this.#closed) return
+    this.#closed = true
+    this.#emit({
+      type: 'chat.disconnected',
+      activeSessions: [...this.#sessions.values()],
+    })
+    for (const jobs of this.#jobsByRuntimeSession.values()) {
+      for (const job of [...jobs]) {
+        if (this.#terminalJobIDs.has(job.id)) continue
+        const disconnectedJob: ChatPushJob = {
+          ...job,
+          metadata: { ...job.metadata, disconnectedAt: this.now() },
+        }
+        this.#replaceJob(disconnectedJob)
+        this.#enqueue(() => this.coordinator.saveChatJob(disconnectedJob))
+        if (this.coordinator.recoverChatJob) {
+          this.#enqueue(() => this.coordinator.recoverChatJob!(disconnectedJob))
+        }
+      }
+    }
+  }
+
+  async flush(): Promise<void> {
+    await Promise.allSettled(
+      [...this.#sessions.values()].flatMap(session => session.baselinePromise ? [session.baselinePromise] : []),
+    )
+    await Promise.resolve()
+    await this.#delivery
+  }
+
+  #emit(observation: ChatObservationPayload): void {
+    const event = {
+      ...observation,
+      localUserID: this.identity.localUserID,
+      connectionID: this.#connectionID,
+      accountKey: this.identity.accountKey,
+      source: this.identity.source,
+      observedAt: this.now(),
+    } as ChatPushObservation
+    if (this.coordinator.observeChat) this.#enqueue(() => this.coordinator.observeChat!(event))
+  }
+
+  #enqueue(operation: () => MaybePromise<void>): void {
+    this.#delivery = this.#delivery.then(operation).catch(() => undefined)
+  }
+
+  #replaceJob(job: ChatPushJob): void {
+    const jobs = this.#jobsByRuntimeSession.get(job.runtimeSessionID) ?? []
+    const index = jobs.findIndex(candidate => candidate.id === job.id)
+    if (index >= 0) jobs[index] = job
+    else jobs.push(job)
+    this.#jobsByRuntimeSession.set(job.runtimeSessionID, jobs)
+  }
+
+  #applyBaseline(runtimeSessionID: string, jobID: string, baseline: ChatMessageBaseline): void {
+    const jobs = this.#jobsByRuntimeSession.get(runtimeSessionID) ?? []
+    const index = jobs.findIndex(job => job.id === jobID)
+    if (index < 0 || this.#terminalJobIDs.has(jobID)) return
+    const current = jobs[index]!
+    const updated: ChatPushJob = {
+      ...current,
+      metadata: {
+        ...current.metadata,
+        baselineCaptured: true,
+        baselineMessageID: baseline.messageID,
+        baselineSequence: baseline.sequence,
+        baselineTotal: baseline.total,
+        baselineAssistantCount: baseline.assistantCount,
+        baselineRowSequence: baseline.lastRowSequence,
+      },
+    }
+    jobs[index] = updated
+    for (const pending of this.#pendingPrompts.values()) {
+      if (pending.job.id === jobID) pending.job = updated
+    }
+    this.#enqueue(() => this.coordinator.saveChatJob(updated))
+  }
+
+  #removeJob(job: ChatPushJob): void {
+    const jobs = (this.#jobsByRuntimeSession.get(job.runtimeSessionID) ?? [])
+      .filter(candidate => candidate.id !== job.id)
+    if (jobs.length) this.#jobsByRuntimeSession.set(job.runtimeSessionID, jobs)
+    else this.#jobsByRuntimeSession.delete(job.runtimeSessionID)
+  }
+
+  #observeRPCEvent(eventType: string, runtimeSessionID: string, payload: JsonRecord): void {
+    const jobs = this.#jobsByRuntimeSession.get(runtimeSessionID) ?? []
+    const eventRunID = string(payload.run_id ?? payload.runId ?? payload.turn_id ?? payload.turnId)
+    let job = eventRunID
+      ? jobs.find(candidate => string(candidate.metadata?.runID) === eventRunID)
+        ?? jobs.find(candidate => !string(candidate.metadata?.runID))
+      : jobs[0]
+    if (!job) return
+    if (eventRunID && !string(job.metadata?.runID)) {
+      job = { ...job, metadata: { ...job.metadata, runID: eventRunID } }
+      this.#replaceJob(job)
+      const boundJob = job
+      this.#enqueue(() => this.coordinator.saveChatJob(boundJob))
+    }
+    if (['approval.request', 'approval.requested', 'clarify.request', 'clarify.requested'].includes(eventType)) {
+      const requestIDValue = string(payload.request_id ?? payload.requestId ?? payload.id)
+      if (!requestIDValue) return
+      const clarification = eventType.startsWith('clarify.')
+      const kind: ChatPushCandidate['kind'] = clarification
+        ? 'chat.clarification.requested' : 'chat.approval.requested'
+      const eventID = `chat-interaction:${job.id}:${requestIDValue}`
+      const candidate: ChatPushCandidate = {
+        eventID,
+        localUserID: job.localUserID,
+        kind,
+        jobID: job.id,
+        profile: job.profile,
+        sessionID: job.storedSessionID,
+        requestID: requestIDValue,
+        title: clarification ? 'Agent 需要补充信息' : 'Agent 请求审批',
+        body: summary(payload.question ?? payload.message ?? payload.prompt, clarification ? '请打开夭夭继续处理' : '请打开夭夭审批'),
+        collapseID: collapseID('chat-interaction', `${job.storedSessionID ?? runtimeSessionID}:${requestIDValue}`),
+        data: notificationData(eventID, kind, job.localUserID, {
+          profile: job.profile, sessionId: job.storedSessionID, requestId: requestIDValue,
+        }),
+      }
+      this.#enqueue(async () => {
+        if (!await this.#jobOwnershipAllowed(job)) return
+        if (!eventRunID) {
+          let ownership: ChatTerminalContent = {}
+          try {
+            ownership = await this.notificationResolver?.resolveTerminal(job, eventType, payload) ?? {}
+          } catch { return }
+          if (ownership.correlated !== true) return
+        }
+        await this.coordinator.enqueueNotification(candidate)
+      })
+      return
+    }
+    const terminal = ['message.complete', 'run.completed', 'run.failed', 'error'].includes(eventType)
+    if (!terminal) return
+    if (this.#terminalJobIDs.has(job.id)) return
+    this.#terminalJobIDs.add(job.id)
+    this.#enqueue(async () => {
+      if (!await this.#jobOwnershipAllowed(job)) return
+      let resolved: ChatTerminalContent | undefined
+      if (!eventRunID) {
+        try { resolved = await this.notificationResolver?.resolveTerminal(job, eventType, payload) ?? {} } catch { resolved = {} }
+        const failed = eventType === 'run.failed' || eventType === 'error' || Boolean(payload.error)
+        if (resolved.confirmed !== true && !(failed && resolved.correlated === true)) {
+          this.#terminalJobIDs.delete(job.id)
+          return
+        }
+      }
+      const candidate = await chatTerminalCandidate(job, eventType, payload, this.notificationResolver, resolved)
+      await this.coordinator.enqueueNotification(candidate)
+      await this.coordinator.completeChatJob(job.id)
+      this.#removeJob(job)
+    })
+  }
+
+  async #jobOwnershipAllowed(job: ChatPushJob): Promise<boolean> {
+    if (!this.coordinator.canRecoverChatJob) return true
+    try { return await this.coordinator.canRecoverChatJob(job) } catch { return false }
+  }
+}
+
+export interface GroupEventEnvelope {
+  type: 'group.ready' | 'group.event' | 'group.heartbeat' | 'group.reset_required'
+  epoch?: string
+  cursor?: number
+  heartbeatSeconds?: number
+  roomId?: string
+  event?: string
+  payload?: unknown
+  reason?: string
+}
+
+function canonicalAnchor(value: GroupWatchAnchor): GroupWatchAnchor {
+  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.epoch)
+    || !Number.isSafeInteger(value.cursor) || value.cursor < 0) {
+    throw new Error('Invalid Yaoyao group event anchor')
+  }
+  return { epoch: value.epoch, cursor: value.cursor }
+}
+
+function eventRoomID(envelope: GroupEventEnvelope, payload: JsonRecord): string {
+  return string(envelope.roomId ?? payload.roomId ?? payload.room_id)
+}
+
+function terminalGroupCandidate(
+  envelope: GroupEventEnvelope,
+  anchor: GroupWatchAnchor,
+): Omit<GroupPushCandidate, 'localUserID' | 'data'> | undefined {
+  if (envelope.type !== 'group.event') return undefined
+  const payload = record(envelope.payload)
+  const roomID = eventRoomID(envelope, payload)
+  if (!roomID) return undefined
+  if (envelope.event === 'message.upsert') {
+    const senderKind = string(payload.senderKind ?? payload.sender_kind).toLowerCase()
+    const status = string(payload.status).toLowerCase()
+    const messageID = string(payload.id)
+    if (senderKind !== 'agent' || !messageID || (status !== 'completed' && status !== 'failed')) return undefined
+    const kind: GroupPushCandidate['kind'] = status === 'completed'
+      ? 'group.message.completed' : 'group.message.failed'
+    const eventID = `group-message:${messageID}:${status}`
+    const content = string(payload.content)
+    const error = string(payload.error)
+    return {
+      eventID,
+      kind,
+      epoch: anchor.epoch,
+      cursor: anchor.cursor + 1,
+      roomID,
+      topicID: string(payload.topicId ?? payload.topic_id) || undefined,
+      messageID,
+      messageSequence: Number.isSafeInteger(Number(payload.seq ?? payload.sequence))
+        ? Number(payload.seq ?? payload.sequence) : undefined,
+      senderName: string(payload.senderName ?? payload.sender_name) || undefined,
+      content: content || undefined,
+      error: error || undefined,
+      occurredAt: eventTimestamp(payload.updatedAt ?? payload.updated_at ?? payload.createdAt ?? payload.created_at),
+      title: status === 'completed'
+        ? `${string(payload.senderName ?? payload.sender_name) || 'Agent'} 已回复`
+        : `${string(payload.senderName ?? payload.sender_name) || 'Agent'} 执行失败`,
+      body: summary(error || content, status === 'completed' ? '打开夭夭查看团队消息' : '打开夭夭查看失败详情'),
+      collapseID: collapseID('group-message', messageID),
+    }
+  }
+  if (envelope.event !== 'interaction.requested') return undefined
+  const interactionID = string(payload.id)
+  const status = string(payload.status).toLowerCase()
+  if (!interactionID || (status && status !== 'pending')) return undefined
+  const rawKind = string(payload.kind).toLowerCase()
+  const interactionKind = rawKind === 'approval' || rawKind === 'clarification' ? rawKind : 'unknown'
+  const interactionPayload = record(payload.payload)
+  const eventID = `group-interaction:${interactionID}`
+  const content = string(interactionPayload.question ?? interactionPayload.message ?? interactionPayload.prompt)
+  return {
+    eventID,
+    kind: 'group.interaction.requested',
+    epoch: anchor.epoch,
+    cursor: anchor.cursor + 1,
+    roomID,
+    topicID: string(payload.topicId ?? payload.topic_id) || undefined,
+    interactionID,
+    interactionKind,
+    content: content || undefined,
+    occurredAt: eventTimestamp(payload.updatedAt ?? payload.updated_at ?? payload.createdAt ?? payload.created_at),
+    title: interactionKind === 'clarification' ? '团队 Agent 需要补充信息' : '团队 Agent 请求审批',
+    body: summary(content, '请打开夭夭继续处理'),
+    collapseID: collapseID('group-interaction', interactionID),
+  }
+}
+
+export type GroupEnvelopeResult = 'ready' | 'heartbeat' | 'advanced' | 'ignored' | 'reset'
+
+/** Applies strict epoch/cursor continuity and extracts only push-worthy events. */
+export class GroupPushEventProcessor {
+  #anchor: GroupWatchAnchor
+  readonly #recent = new Map<string, true>()
+
+  constructor(
+    anchor: GroupWatchAnchor,
+    readonly coordinator: PushEventCoordinator,
+    readonly recentLimit = 4_096,
+  ) {
+    this.#anchor = canonicalAnchor(anchor)
+  }
+
+  get anchor(): GroupWatchAnchor { return { ...this.#anchor } }
+
+  async accept(envelope: GroupEventEnvelope): Promise<GroupEnvelopeResult> {
+    if (envelope.type === 'group.reset_required') {
+      const proposed = canonicalAnchor({
+        epoch: string(envelope.epoch) || this.#anchor.epoch,
+        cursor: envelope.cursor ?? this.#anchor.cursor,
+      })
+      const recovered = await this.coordinator.resetGroupCursor({
+        ...proposed,
+        reason: string(envelope.reason) || 'server_reset',
+      })
+      this.#anchor = canonicalAnchor(recovered ?? proposed)
+      this.#recent.clear()
+      return 'reset'
+    }
+    if (envelope.epoch && envelope.epoch !== this.#anchor.epoch) {
+      throw new Error('Yaoyao group event epoch changed without reset')
+    }
+    if (envelope.type === 'group.ready') {
+      if (envelope.cursor !== this.#anchor.cursor) throw new Error('Yaoyao group ready cursor mismatch')
+      return 'ready'
+    }
+    if (envelope.type === 'group.heartbeat') {
+      if (envelope.cursor != null && envelope.cursor !== this.#anchor.cursor) {
+        throw new Error('Yaoyao group heartbeat cursor mismatch')
+      }
+      return 'heartbeat'
+    }
+    if (envelope.type !== 'group.event' || !Number.isSafeInteger(envelope.cursor) || envelope.cursor! < 0) {
+      throw new Error('Invalid Yaoyao group event envelope')
+    }
+    if (envelope.cursor! <= this.#anchor.cursor) return 'ignored'
+    if (envelope.cursor !== this.#anchor.cursor + 1) throw new Error('Yaoyao group event cursor gap')
+
+    const candidate = terminalGroupCandidate(envelope, this.#anchor)
+    if (candidate) {
+      const subscribers = [...new Set((await this.coordinator.groupSubscribers(candidate.roomID))
+        .map(value => value.trim()).filter(Boolean))]
+      for (const localUserID of subscribers) {
+        const dedupeKey = `${localUserID}:${candidate.eventID}`
+        if (this.#recent.has(dedupeKey)) continue
+        await this.coordinator.enqueueNotification({
+          ...candidate,
+          localUserID,
+          data: notificationData(candidate.eventID, candidate.kind, localUserID, {
+            roomId: candidate.roomID,
+            topicId: candidate.topicID,
+            messageId: candidate.messageID,
+            interactionId: candidate.interactionID,
+          }),
+        })
+        this.#remember(dedupeKey)
+      }
+    }
+    const next = { epoch: this.#anchor.epoch, cursor: envelope.cursor }
+    await this.coordinator.advanceGroupCursor(next)
+    this.#anchor = next
+    return 'advanced'
+  }
+
+  #remember(key: string): void {
+    this.#recent.delete(key)
+    this.#recent.set(key, true)
+    while (this.#recent.size > this.recentLimit) {
+      const oldest = this.#recent.keys().next().value as string | undefined
+      if (!oldest) break
+      this.#recent.delete(oldest)
+    }
+  }
+}
+
+export interface GroupEventConnection {
+  close(): void
+}
+
+export interface GroupEventSource {
+  currentAnchor(): Promise<GroupWatchAnchor>
+  connect(
+    anchor: GroupWatchAnchor,
+    handlers: {
+      onEnvelope(envelope: GroupEventEnvelope): void
+      onClose(error?: Error): void
+    },
+  ): Promise<GroupEventConnection>
+}
+
+function parseUpstreamJSON(body: Buffer): JsonRecord {
+  try { return record(JSON.parse(body.toString('utf8'))) } catch { throw new Error('Hermes returned invalid JSON') }
+}
+
+function values(value: unknown): unknown[] {
+  if (Array.isArray(value)) return value
+  const source = record(value)
+  const data = source.data
+  return Array.isArray(data) ? data : []
+}
+
+function messageContent(value: unknown): string {
+  if (typeof value === 'string') return value.trim()
+  if (!Array.isArray(value)) return ''
+  return value.map(item => {
+    if (typeof item === 'string') return item
+    const part = record(item)
+    return string(part.text ?? part.content)
+  }).filter(Boolean).join('\n').trim()
+}
+
+function messageTimestamp(value: unknown): number | undefined {
+  if (typeof value === 'number' && Number.isFinite(value)) return value < 1_000_000_000_000 ? value * 1_000 : value
+  if (typeof value === 'string' && value.trim()) {
+    const numeric = Number(value)
+    if (Number.isFinite(numeric)) return numeric < 1_000_000_000_000 ? numeric * 1_000 : numeric
+    const parsed = Date.parse(value)
+    if (!Number.isNaN(parsed)) return parsed
+  }
+  return undefined
+}
+
+interface ChatHistoryRow {
+  role: string
+  content: string
+  messageID?: string
+  runID?: string
+  sequence: number
+  timestamp?: number
+  failed: boolean
+  error?: string
+}
+
+export class HermesChatNotificationResolver implements ChatNotificationResolver {
+  private readonly titleCache = new Map<string, { value: string; expiresAt: number }>()
+
+  constructor(
+    readonly upstreamSession: UpstreamServiceSession,
+    readonly digestPrompt?: (localUserID: string, prompt: string) => MaybePromise<string>,
+  ) {}
+
+  private async snapshot(job: ChatPushJob): Promise<{
+    title?: string
+    total: number
+    rows: ChatHistoryRow[]
+    assistants: ChatHistoryRow[]
+    latest?: ChatHistoryRow
+  }> {
+    if (!job.storedSessionID) return { total: 0, rows: [], assistants: [] }
+    const search = new URLSearchParams({
+      offset: '0', limit: '500', order: 'latest', include_compacted: 'true',
+    })
+    if (job.profile) search.set('profile', job.profile)
+    const response = await this.upstreamSession.request(
+      `/api/sessions/${encodeURIComponent(job.storedSessionID)}/messages`,
+      { search },
+    )
+    if (response.status < 200 || response.status >= 300) throw new Error(`Hermes history failed (${response.status})`)
+    const source = parseUpstreamJSON(response.body)
+    const session = record(source.session)
+    const messages = values(source.messages ?? source.items ?? source.data ?? source)
+    const rows = messages.map((raw, index): ChatHistoryRow => {
+      const message = record(raw)
+      const role = string(message.role ?? message.senderKind ?? message.sender_kind).toLowerCase()
+      const content = messageContent(message.content ?? message.text ?? message.message)
+      const error = string(message.error)
+      const status = string(message.status).toLowerCase()
+      const rowID = Number(message.id)
+      const explicitSequence = Number(message.sequence ?? message.seq ?? message.index)
+      const rawSequence = Number.isFinite(rowID)
+        ? rowID : Number.isFinite(explicitSequence) ? explicitSequence : index
+      return {
+        role,
+        content: content || error,
+        messageID: requestID(message.id ?? message.message_id ?? message.messageId) || undefined,
+        runID: string(message.run_id ?? message.runId ?? message.turn_id ?? message.turnId) || undefined,
+        sequence: rawSequence,
+        timestamp: messageTimestamp(message.timestamp ?? message.createdAt ?? message.created_at),
+        failed: status === 'failed' || status === 'error' || Boolean(error),
+        ...(error ? { error } : {}),
+      }
+    }).sort((left, right) => left.sequence - right.sequence || (left.timestamp ?? 0) - (right.timestamp ?? 0))
+    const assistants = rows.filter(row => (row.role === 'assistant' || row.role === 'agent') && Boolean(row.content))
+    const pagination = record(source.pagination)
+    const totalValue = Number(pagination.total ?? source.total ?? messages.length)
+    let title = string(session.title ?? source.title) || undefined
+    if (!title) title = await this.sessionTitle(job)
+    return {
+      title,
+      total: Number.isSafeInteger(totalValue) && totalValue >= 0 ? totalValue : messages.length,
+      rows,
+      assistants,
+      latest: assistants.at(-1),
+    }
+  }
+
+  private async sessionTitle(job: ChatPushJob): Promise<string | undefined> {
+    if (!job.storedSessionID) return undefined
+    const key = `${job.profile ?? 'default'}:${job.storedSessionID}`
+    const cached = this.titleCache.get(key)
+    if (cached && cached.expiresAt > Date.now()) return cached.value
+    const search = new URLSearchParams()
+    if (job.profile) search.set('profile', job.profile)
+    try {
+      const response = await this.upstreamSession.request(
+        `/api/sessions/${encodeURIComponent(job.storedSessionID)}`,
+        { search },
+      )
+      if (response.status < 200 || response.status >= 300) return undefined
+      const payload = parseUpstreamJSON(response.body)
+      const data = record(payload.data)
+      const session = record(payload.session ?? data.session ?? payload.data ?? payload)
+      const title = string(session.title)
+      if (!title) return undefined
+      this.titleCache.set(key, { value: title, expiresAt: Date.now() + 5 * 60 * 1_000 })
+      return title
+    } catch {
+      return undefined
+    }
+  }
+
+  async captureBaseline(job: ChatPushJob): Promise<ChatMessageBaseline> {
+    if (!job.storedSessionID) {
+      return { total: 0, assistantCount: 0, lastRowSequence: 0 }
+    }
+    const search = new URLSearchParams({
+      offset: '0', limit: '1', order: 'latest', include_compacted: 'true',
+    })
+    if (job.profile) search.set('profile', job.profile)
+    const response = await this.upstreamSession.request(
+      `/api/sessions/${encodeURIComponent(job.storedSessionID)}/messages`,
+      { search },
+    )
+    if (response.status < 200 || response.status >= 300) throw new Error(`Hermes history failed (${response.status})`)
+    const source = parseUpstreamJSON(response.body)
+    const messages = values(source.messages ?? source.items ?? source.data ?? source)
+    const raw = messages.at(-1)
+    const message = record(raw)
+    const role = string(message.role ?? message.senderKind ?? message.sender_kind).toLowerCase()
+    const rowID = Number(message.id)
+    const explicitSequence = Number(message.sequence ?? message.seq ?? message.index)
+    const sequence = Number.isFinite(rowID)
+      ? rowID : Number.isFinite(explicitSequence) ? explicitSequence : 0
+    const pagination = record(source.pagination)
+    const totalValue = Number(pagination.total ?? source.total ?? messages.length)
+    return {
+      messageID: role === 'assistant' || role === 'agent'
+        ? requestID(message.id ?? message.message_id ?? message.messageId) || undefined
+        : undefined,
+      sequence: role === 'assistant' || role === 'agent' ? sequence : undefined,
+      total: Number.isSafeInteger(totalValue) && totalValue >= 0 ? totalValue : messages.length,
+      assistantCount: role === 'assistant' || role === 'agent' ? 1 : 0,
+      lastRowSequence: sequence,
+    }
+  }
+
+  async resolveTerminal(job: ChatPushJob, _eventType: string, _payload: JsonRecord): Promise<ChatTerminalContent> {
+    const snapshot = await this.snapshot(job)
+    const expectedRunID = string(job.metadata?.runID)
+    if (expectedRunID) {
+      const latest = snapshot.assistants.filter(candidate => candidate.runID === expectedRunID).at(-1)
+      const baselineSequence = Number(job.metadata?.baselineSequence)
+      const baselineRowSequence = Number(job.metadata?.baselineRowSequence)
+      const baselineMessageID = string(job.metadata?.baselineMessageID)
+      const confirmed = job.metadata?.baselineCaptured === true && Boolean(latest) && (
+        (Number.isFinite(baselineSequence) && latest!.sequence > baselineSequence)
+          || (Number.isFinite(baselineRowSequence) && latest!.sequence > baselineRowSequence)
+          || Boolean(baselineMessageID && latest!.messageID && latest!.messageID !== baselineMessageID)
+      )
+      return this.terminalContent(snapshot.title, latest, confirmed, confirmed)
+    }
+
+    const promptDigest = string(job.metadata?.promptDigest)
+    const baselineRowSequence = Number(job.metadata?.baselineRowSequence)
+    if (!this.digestPrompt || !promptDigest || !Number.isFinite(baselineRowSequence)) {
+      return { title: snapshot.title, confirmed: false, correlated: false }
+    }
+    const matchingUsers: ChatHistoryRow[] = []
+    for (const row of snapshot.rows) {
+      if (row.role !== 'user' || row.sequence <= baselineRowSequence || !row.content) continue
+      let digest = ''
+      try { digest = await this.digestPrompt(job.localUserID, row.content) } catch { continue }
+      if (digest === promptDigest) matchingUsers.push(row)
+    }
+    if (matchingUsers.length !== 1) {
+      return { title: snapshot.title, confirmed: false, correlated: false }
+    }
+    const userRow = matchingUsers[0]!
+    const nextUser = snapshot.rows.find(row => row.role === 'user' && row.sequence > userRow.sequence)
+    const latest = snapshot.assistants
+      .filter(row => row.sequence > userRow.sequence && (!nextUser || row.sequence < nextUser.sequence))
+      .at(-1)
+    return this.terminalContent(snapshot.title, latest, Boolean(latest), true)
+  }
+
+  private terminalContent(
+    title: string | undefined,
+    latest: ChatHistoryRow | undefined,
+    confirmed: boolean,
+    correlated: boolean,
+  ): ChatTerminalContent {
+    return {
+      title,
+      body: latest?.content,
+      messageID: latest?.messageID,
+      timestamp: latest?.timestamp,
+      confirmed,
+      correlated,
+      ...(latest?.failed ? { failed: true } : {}),
+      ...(latest?.error ? { error: latest.error } : {}),
+    }
+  }
+}
+
+function chatInteractionCandidate(
+  job: ChatPushJob,
+  eventType: string,
+  payload: JsonRecord,
+): ChatPushCandidate | undefined {
+  const requestIDValue = string(payload.request_id ?? payload.requestId ?? payload.id)
+  if (!requestIDValue) return undefined
+  const clarification = eventType.startsWith('clarify.')
+    || string(payload.kind).toLowerCase() === 'clarification'
+  const kind: ChatPushCandidate['kind'] = clarification
+    ? 'chat.clarification.requested' : 'chat.approval.requested'
+  const eventID = `chat-interaction:${job.id}:${requestIDValue}`
+  return {
+    eventID,
+    localUserID: job.localUserID,
+    kind,
+    jobID: job.id,
+    profile: job.profile,
+    sessionID: job.storedSessionID,
+    requestID: requestIDValue,
+    title: clarification ? 'Agent 需要补充信息' : 'Agent 请求审批',
+    body: summary(payload.question ?? payload.message ?? payload.prompt, clarification ? '请打开夭夭继续处理' : '请打开夭夭审批'),
+    collapseID: collapseID('chat-interaction', `${job.storedSessionID ?? job.runtimeSessionID}:${requestIDValue}`),
+    data: notificationData(eventID, kind, job.localUserID, {
+      profile: job.profile, sessionId: job.storedSessionID, requestId: requestIDValue,
+    }),
+  }
+}
+
+async function chatTerminalCandidate(
+  job: ChatPushJob,
+  eventType: string,
+  payload: JsonRecord,
+  resolver?: ChatNotificationResolver,
+  suppliedContent?: ChatTerminalContent,
+): Promise<ChatPushCandidate> {
+  const terminalStatus = string(payload.status).toLowerCase()
+  const failed = eventType === 'run.failed' || eventType === 'error'
+    || terminalStatus === 'failed' || terminalStatus === 'error' || Boolean(payload.error)
+  let resolved: ChatTerminalContent = suppliedContent ?? {}
+  if (!suppliedContent) {
+    try { resolved = await resolver?.resolveTerminal(job, eventType, payload) ?? {} } catch { /* payload fallback */ }
+  }
+  const kind: ChatPushCandidate['kind'] = failed ? 'chat.failed' : 'chat.completed'
+  const eventID = `chat-terminal:${job.id}:${failed ? 'failed' : 'completed'}`
+  const authoritativeBody = resolved.confirmed === true ? resolved.body : undefined
+  return {
+    eventID,
+    localUserID: job.localUserID,
+    kind,
+    jobID: job.id,
+    profile: job.profile,
+    sessionID: job.storedSessionID,
+    title: resolved.title || (failed ? '任务执行失败' : '任务已完成'),
+    body: failed
+      ? summary(payload.error ?? payload.message ?? authoritativeBody, '打开夭夭查看失败详情')
+      : summary(authoritativeBody ?? payload.text ?? payload.message, '打开夭夭查看结果'),
+    collapseID: collapseID('chat', job.storedSessionID ?? job.runtimeSessionID),
+    data: notificationData(eventID, kind, job.localUserID, {
+      profile: job.profile, sessionId: job.storedSessionID,
+    }),
+  }
+}
+
+/**
+ * Server-owned recovery connection for one persisted chat job. The caller
+ * controls the number of concurrently started watchers.
+ */
+export class HermesChatPushJobWatcher {
+  #job: ChatPushJob
+  #running = false
+  #generation = 0
+  #retryAttempt = 0
+  #retryTimer?: ReturnType<typeof setTimeout>
+  #socket?: WebSocket
+  #resumeRequestID = ''
+  #frames = Promise.resolve()
+  #finished = false
+
+  constructor(
+    job: ChatPushJob,
+    readonly config: ServerConfig,
+    readonly upstreamSession: UpstreamServiceSession,
+    readonly coordinator: PushEventCoordinator,
+    readonly resolver: ChatNotificationResolver = new HermesChatNotificationResolver(upstreamSession),
+    readonly now: () => number = Date.now,
+    readonly onFinished?: (jobID: string) => void,
+  ) {
+    this.#job = { ...job }
+  }
+
+  start(): void {
+    if (this.#running) return
+    this.#running = true
+    this.#generation += 1
+    void this.#connect(this.#generation)
+  }
+
+  stop(): void {
+    this.#running = false
+    this.#generation += 1
+    if (this.#retryTimer) clearTimeout(this.#retryTimer)
+    this.#retryTimer = undefined
+    this.#socket?.close(1000, 'chat push watcher close')
+    this.#socket = undefined
+  }
+
+  async #connect(generation: number): Promise<void> {
+    if (!this.#job.storedSessionID || this.#job.expiresAt <= this.now()) {
+      await this.coordinator.completeChatJob(this.#job.id)
+      this.stop()
+      this.#notifyFinished()
+      return
+    }
+    try {
+      const ticketResponse = await this.upstreamSession.request('/api/auth/ws-ticket', { method: 'POST' })
+      if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
+        throw new Error(`Hermes WebSocket ticket failed (${ticketResponse.status})`)
+      }
+      const ticket = string(parseUpstreamJSON(ticketResponse.body).ticket)
+      if (!ticket) throw new Error('Hermes returned an empty WebSocket ticket')
+      if (!this.#running || generation !== this.#generation) return
+      const url = new URL(this.config.upstream)
+      url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+      const prefix = this.config.upstream.pathname === '/' ? '' : this.config.upstream.pathname.replace(/\/$/, '')
+      url.pathname = `${prefix}/api/ws`
+      url.search = ''
+      url.searchParams.set('ticket', ticket)
+      const socket = new WebSocket(url, {
+        headers: { Origin: this.config.upstream.origin },
+        maxPayload: 36 * 1_024 * 1_024,
+        handshakeTimeout: 15_000,
+      })
+      this.#socket = socket
+      socket.on('message', (data, isBinary) => this.#queueFrame(generation, data, isBinary))
+      socket.once('error', () => {
+        if (socket.readyState !== WebSocket.OPEN) this.#scheduleReconnect(generation)
+      })
+      socket.once('close', () => this.#scheduleReconnect(generation))
+    } catch {
+      this.#scheduleReconnect(generation)
+    }
+  }
+
+  #queueFrame(generation: number, data: RawData, isBinary: boolean): void {
+    this.#frames = this.#frames.then(async () => {
+      if (!this.#running || generation !== this.#generation || isBinary) return
+      let frame: JsonRecord
+      try { frame = record(JSON.parse(Buffer.from(data as Uint8Array).toString('utf8'))) } catch { return }
+      if (string(frame.method) === 'event') {
+        const params = record(frame.params)
+        const eventType = string(params.type)
+        if (eventType === 'gateway.ready') {
+          this.#resumeRequestID = `push-resume:${this.#job.id}:${generation}`
+          this.#socket?.send(JSON.stringify({
+            jsonrpc: '2.0',
+            id: this.#resumeRequestID,
+            method: 'session.resume',
+            params: {
+              session_id: this.#job.storedSessionID,
+              profile: this.#job.profile,
+              source: 'web',
+              close_on_disconnect: false,
+              omit_messages: true,
+              cols: 80,
+            },
+          }))
+          return
+        }
+        const runtimeSessionID = string(params.session_id)
+        if (runtimeSessionID && this.#job.runtimeSessionID && runtimeSessionID !== this.#job.runtimeSessionID) return
+        const payload = record(params.payload)
+        const expectedRunID = string(this.#job.metadata?.runID)
+        const eventRunID = string(payload.run_id ?? payload.runId ?? payload.turn_id ?? payload.turnId)
+        if (expectedRunID && eventRunID !== expectedRunID) return
+        if (['approval.request', 'approval.requested', 'clarify.request', 'clarify.requested'].includes(eventType)) {
+          if (!await this.#ownershipStillAllowed()) return
+          if (!expectedRunID) {
+            let ownership: ChatTerminalContent = {}
+            try { ownership = await this.resolver.resolveTerminal(this.#job, eventType, payload) } catch { return }
+            if (ownership.correlated !== true) return
+          }
+          const candidate = chatInteractionCandidate(this.#job, eventType, payload)
+          if (candidate) await this.coordinator.enqueueNotification(candidate)
+          return
+        }
+        if (['message.complete', 'run.completed', 'run.failed', 'error'].includes(eventType)) {
+          await this.#finishTerminal(eventType, payload)
+        }
+        return
+      }
+      if (requestID(frame.id) !== this.#resumeRequestID) return
+      if (frame.error) throw new Error(errorMessage(frame.error) || 'Unable to resume chat push job')
+      const result = record(frame.result)
+      const runtimeSessionID = string(result.session_id ?? result.sessionId)
+      const storedSessionID = string(result.stored_session_id ?? result.storedSessionId ?? result.session_key)
+        || this.#job.storedSessionID
+      const resumedRunID = string(result.run_id ?? result.runId ?? result.turn_id ?? result.turnId)
+      const expectedRunID = string(this.#job.metadata?.runID)
+      if (!runtimeSessionID) throw new Error('Hermes resume did not return a runtime session ID')
+      const promptDigest = string(this.#job.metadata?.promptDigest)
+      if ((!expectedRunID && !promptDigest) || (expectedRunID && resumedRunID && resumedRunID !== expectedRunID)) {
+        this.#socket?.close(1012, 'push run ownership is not confirmed')
+        return
+      }
+      this.#job = {
+        ...this.#job,
+        runtimeSessionID,
+        storedSessionID,
+        phase: 'watching',
+        metadata: {
+          ...this.#job.metadata,
+          ...(expectedRunID ? { runID: expectedRunID } : {}),
+        },
+      }
+      await this.coordinator.saveChatJob(this.#job)
+      const approval = record(result.pending_approval)
+      const clarification = record(result.pending_clarify)
+      const pending = string(approval.request_id ?? approval.requestId ?? approval.id)
+        ? chatInteractionCandidate(this.#job, 'approval.requested', approval)
+        : string(clarification.request_id ?? clarification.requestId ?? clarification.id)
+          ? chatInteractionCandidate(this.#job, 'clarify.requested', clarification)
+          : undefined
+      if (pending) {
+        if (!await this.#ownershipStillAllowed()) return
+        if (!expectedRunID) {
+          let ownership: ChatTerminalContent = {}
+          try { ownership = await this.resolver.resolveTerminal(this.#job, 'interaction.requested', result) } catch { return }
+          if (ownership.correlated !== true) return
+        }
+        await this.coordinator.enqueueNotification(pending)
+        return
+      }
+      const inflight = record(result.inflight ?? result.active_run)
+      const queued = record(result.queued)
+      const running = result.running === true || inflight.streaming === true
+        || Boolean(string(queued.user ?? queued.text))
+      if (running && expectedRunID && !resumedRunID) {
+        this.#socket?.close(1012, 'active push run lacks ownership identity')
+        return
+      }
+      if (running) this.#retryAttempt = 0
+      if (!running) {
+        const resumeError = string(result.error ?? inflight.error)
+        if (resumeError) {
+          await this.#finishTerminal('run.failed', { ...result, error: resumeError })
+          return
+        }
+        let resolved: ChatTerminalContent = {}
+        try { resolved = await this.resolver.resolveTerminal(this.#job, 'run.completed', result) } catch { /* retry below */ }
+        if (resolved.confirmed !== true) {
+          this.#socket?.close(1012, 'awaiting authoritative terminal message')
+          return
+        }
+        await this.#finishTerminal(
+          resolved.failed ? 'run.failed' : 'run.completed',
+          resolved.failed ? { ...result, error: resolved.error ?? resolved.body } : result,
+          resolved,
+        )
+      }
+    }).catch(() => {
+      if (!this.#running || generation !== this.#generation) return
+      this.#socket?.close(1011, 'chat push watcher frame failed')
+      this.#scheduleReconnect(generation)
+    })
+  }
+
+  async #finishTerminal(eventType: string, payload: JsonRecord, resolved?: ChatTerminalContent): Promise<void> {
+    if (!this.#running) return
+    if (!await this.#ownershipStillAllowed()) {
+      this.stop()
+      this.#notifyFinished()
+      return
+    }
+    let ownership = resolved
+    if (!ownership) {
+      try { ownership = await this.resolver.resolveTerminal(this.#job, eventType, payload) } catch { ownership = {} }
+    }
+    const expectedRunID = string(this.#job.metadata?.runID)
+    const eventRunID = string(payload.run_id ?? payload.runId ?? payload.turn_id ?? payload.turnId)
+    const ownedByRun = Boolean(expectedRunID && eventRunID === expectedRunID)
+    const failed = eventType === 'run.failed' || eventType === 'error' || Boolean(payload.error)
+    if (ownership.confirmed !== true && !ownedByRun && !(failed && ownership.correlated === true)) {
+      this.#socket?.close(1012, 'terminal push ownership is not confirmed')
+      return
+    }
+    const candidate = await chatTerminalCandidate(this.#job, eventType, payload, this.resolver, ownership)
+    await this.coordinator.enqueueNotification(candidate)
+    await this.coordinator.completeChatJob(this.#job.id)
+    this.stop()
+    this.#notifyFinished()
+  }
+
+  #scheduleReconnect(generation: number): void {
+    if (!this.#running || generation !== this.#generation || this.#retryTimer) return
+    this.#socket = undefined
+    const delay = Math.min(60_000, 1_000 * 2 ** Math.min(this.#retryAttempt++, 6))
+    this.#retryTimer = setTimeout(() => {
+      this.#retryTimer = undefined
+      if (this.#running && generation === this.#generation) void this.#connect(generation)
+    }, delay)
+    this.#retryTimer.unref()
+  }
+
+  async #ownershipStillAllowed(): Promise<boolean> {
+    if (!this.coordinator.canRecoverChatJob) return true
+    try { return await this.coordinator.canRecoverChatJob(this.#job) } catch { return false }
+  }
+
+  #notifyFinished(): void {
+    if (this.#finished) return
+    this.#finished = true
+    this.onFinished?.(this.#job.id)
+  }
+}
+
+export interface ChatPushJobManagerOptions {
+  maximumConcurrent?: number
+  reconcileMilliseconds?: number
+  now?: () => number
+  resolver?: ChatNotificationResolver
+}
+
+/** Starts and bounds the server-owned recovery sockets for persisted jobs. */
+export class ChatPushJobManager {
+  readonly #watchers = new Map<string, HermesChatPushJobWatcher>()
+  readonly #maximumConcurrent: number
+  readonly #reconcileMilliseconds: number
+  readonly #now: () => number
+  readonly #resolver: ChatNotificationResolver
+  #running = false
+  #timer?: ReturnType<typeof setInterval>
+  #activeReconcile?: Promise<void>
+
+  constructor(
+    readonly config: ServerConfig,
+    readonly upstreamSession: UpstreamServiceSession,
+    readonly coordinator: PushEventCoordinator,
+    options: ChatPushJobManagerOptions = {},
+  ) {
+    this.#maximumConcurrent = Math.max(1, Math.min(64, Math.trunc(options.maximumConcurrent ?? 16)))
+    this.#reconcileMilliseconds = Math.max(250, Math.trunc(options.reconcileMilliseconds ?? 1_000))
+    this.#now = options.now ?? Date.now
+    this.#resolver = options.resolver ?? new HermesChatNotificationResolver(upstreamSession)
+  }
+
+  start(): void {
+    if (this.#running) return
+    this.#running = true
+    void this.reconcile().catch(() => undefined)
+    this.#timer = setInterval(() => {
+      void this.reconcile().catch(() => undefined)
+    }, this.#reconcileMilliseconds)
+    this.#timer.unref()
+  }
+
+  stop(): void {
+    this.#running = false
+    if (this.#timer) clearInterval(this.#timer)
+    this.#timer = undefined
+    for (const watcher of this.#watchers.values()) watcher.stop()
+    this.#watchers.clear()
+  }
+
+  reconcile(): Promise<void> {
+    if (this.#activeReconcile) return this.#activeReconcile
+    const operation = this.#reconcile()
+    const tracked = operation.finally(() => {
+      if (this.#activeReconcile === tracked) this.#activeReconcile = undefined
+    })
+    this.#activeReconcile = tracked
+    return tracked
+  }
+
+  async #reconcile(): Promise<void> {
+    if (!this.#running) return
+    const pending = [...await this.coordinator.pendingChatJobs()]
+    const now = this.#now()
+    for (const job of pending) {
+      if (job.expiresAt <= now) await this.coordinator.completeChatJob(job.id)
+    }
+    const jobs: ChatPushJob[] = []
+    for (const job of pending) {
+      const structurallyEligible = job.expiresAt > now
+        && job.metadata?.baselineCaptured === true
+        && Number.isFinite(Number(job.metadata?.disconnectedAt))
+        && Boolean(string(job.metadata?.runID) || string(job.metadata?.promptDigest))
+        && (job.phase === 'submitted' || job.phase === 'accepted' || job.phase === 'watching')
+      if (!structurallyEligible) continue
+      if (this.coordinator.canRecoverChatJob
+        && !await this.coordinator.canRecoverChatJob(job)) continue
+      jobs.push(job)
+    }
+    jobs.sort((left, right) => left.submittedAt - right.submittedAt)
+    if (!this.#running) return
+    const pendingIDs = new Set(jobs.map(job => job.id))
+    for (const [jobID, watcher] of this.#watchers) {
+      if (pendingIDs.has(jobID)) continue
+      watcher.stop()
+      this.#watchers.delete(jobID)
+    }
+    for (const job of jobs) {
+      if (!this.#running || this.#watchers.size >= this.#maximumConcurrent) break
+      if (this.#watchers.has(job.id)) continue
+      const watcher = new HermesChatPushJobWatcher(
+        job,
+        this.config,
+        this.upstreamSession,
+        this.coordinator,
+        this.#resolver,
+        this.#now,
+        jobID => {
+          this.#watchers.delete(jobID)
+          if (this.#running) void this.reconcile()
+        },
+      )
+      this.#watchers.set(job.id, watcher)
+      watcher.start()
+    }
+  }
+}
+
+export class HermesGroupEventSource implements GroupEventSource {
+  constructor(
+    readonly config: ServerConfig,
+    readonly upstreamSession: UpstreamServiceSession,
+  ) {}
+
+  async currentAnchor(): Promise<GroupWatchAnchor> {
+    const response = await this.upstreamSession.request('/api/plugins/yaoyao/v1/capabilities')
+    if (response.status < 200 || response.status >= 300) throw new Error(`Yaoyao capabilities failed (${response.status})`)
+    const payload = parseUpstreamJSON(response.body)
+    const protocolVersion = Number(payload.protocolVersion ?? payload.protocol_version)
+    if (!isSupportedGroupProtocolVersion(protocolVersion)) throw new Error('Unsupported Yaoyao group protocol')
+    return canonicalAnchor({
+      epoch: string(payload.journalEpoch ?? payload.journal_epoch),
+      cursor: Number(payload.latestCursor ?? payload.latest_cursor),
+    })
+  }
+
+  async connect(
+    anchor: GroupWatchAnchor,
+    handlers: { onEnvelope(envelope: GroupEventEnvelope): void; onClose(error?: Error): void },
+  ): Promise<GroupEventConnection> {
+    const ticketResponse = await this.upstreamSession.request('/api/auth/ws-ticket', { method: 'POST' })
+    if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
+      throw new Error(`Hermes WebSocket ticket failed (${ticketResponse.status})`)
+    }
+    const ticket = string(parseUpstreamJSON(ticketResponse.body).ticket)
+    if (!ticket) throw new Error('Hermes returned an empty WebSocket ticket')
+    const url = new URL(this.config.upstream)
+    url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+    const prefix = this.config.upstream.pathname === '/' ? '' : this.config.upstream.pathname.replace(/\/$/, '')
+    url.pathname = `${prefix}/api/plugins/yaoyao/v1/events`
+    url.search = ''
+    url.searchParams.set('ticket', ticket)
+    url.searchParams.set('epoch', anchor.epoch)
+    url.searchParams.set('cursor', String(anchor.cursor))
+
+    return await new Promise<GroupEventConnection>((resolve, reject) => {
+      const socket = new WebSocket(url, {
+        headers: { Origin: this.config.upstream.origin },
+        maxPayload: 2 * 1_024 * 1_024,
+        handshakeTimeout: 15_000,
+      })
+      let opened = false
+      let closed = false
+      socket.once('open', () => {
+        opened = true
+        resolve({ close: () => socket.close(1000, 'group push watcher close') })
+      })
+      socket.on('message', (data, isBinary) => {
+        if (isBinary) {
+          socket.close(1008, 'binary group frame')
+          return
+        }
+        try {
+          const value = record(JSON.parse(Buffer.from(data as Uint8Array).toString('utf8')))
+          handlers.onEnvelope({
+            type: string(value.type) as GroupEventEnvelope['type'],
+            epoch: string(value.epoch) || undefined,
+            cursor: value.cursor == null ? undefined : Number(value.cursor),
+            heartbeatSeconds: value.heartbeatSeconds == null && value.heartbeat_seconds == null
+              ? undefined : Number(value.heartbeatSeconds ?? value.heartbeat_seconds),
+            roomId: string(value.roomId ?? value.room_id) || undefined,
+            event: string(value.event) || undefined,
+            payload: value.payload,
+            reason: string(value.reason) || undefined,
+          })
+        } catch {
+          socket.close(1008, 'malformed group frame')
+        }
+      })
+      socket.once('unexpected-response', (_request, response) => {
+        const error = new Error(`Hermes rejected group WebSocket (${response.statusCode ?? 0})`)
+        response.resume()
+        if (!opened) reject(error)
+      })
+      socket.once('error', (error) => {
+        if (!opened) reject(error)
+      })
+      socket.once('close', (code, reason) => {
+        if (closed) return
+        closed = true
+        const error = code === 1000 ? undefined : new Error(reason.toString() || `Group WebSocket closed (${code})`)
+        if (!opened) reject(error ?? new Error('Group WebSocket closed before opening'))
+        else handlers.onClose(error)
+      })
+    })
+  }
+}
+
+export class GroupPushEventWatcher {
+  #running = false
+  #generation = 0
+  #retryAttempt = 0
+  #retryTimer?: ReturnType<typeof setTimeout>
+  #connection?: GroupEventConnection
+  #processor?: GroupPushEventProcessor
+  #frames = Promise.resolve()
+
+  constructor(
+    readonly source: GroupEventSource,
+    readonly coordinator: PushEventCoordinator,
+  ) {}
+
+  start(): void {
+    if (this.#running) return
+    this.#running = true
+    this.#generation += 1
+    void this.#open(this.#generation)
+  }
+
+  stop(): void {
+    this.#running = false
+    this.#generation += 1
+    if (this.#retryTimer) clearTimeout(this.#retryTimer)
+    this.#retryTimer = undefined
+    this.#connection?.close()
+    this.#connection = undefined
+  }
+
+  async #open(generation: number): Promise<void> {
+    try {
+      let anchor = await this.coordinator.groupWatchAnchor()
+      if (!anchor) {
+        anchor = await this.source.currentAnchor()
+        const restored = await this.coordinator.resetGroupCursor({ ...anchor, reason: 'initial' })
+        anchor = restored ?? anchor
+      }
+      if (!this.#running || generation !== this.#generation) return
+      this.#processor = new GroupPushEventProcessor(anchor, this.coordinator)
+      this.#connection = await this.source.connect(anchor, {
+        onEnvelope: envelope => this.#queueEnvelope(generation, envelope),
+        onClose: () => this.#scheduleReconnect(generation),
+      })
+      this.#retryAttempt = 0
+    } catch {
+      this.#scheduleReconnect(generation)
+    }
+  }
+
+  #queueEnvelope(generation: number, envelope: GroupEventEnvelope): void {
+    this.#frames = this.#frames.then(async () => {
+      if (!this.#running || generation !== this.#generation || !this.#processor) return
+      const result = await this.#processor.accept(envelope)
+      if (result === 'reset') {
+        this.#connection?.close()
+        this.#connection = undefined
+        this.#scheduleReconnect(generation, 0)
+      }
+    }).catch(() => {
+      if (!this.#running || generation !== this.#generation) return
+      this.#connection?.close()
+      this.#connection = undefined
+      this.#scheduleReconnect(generation)
+    })
+  }
+
+  #scheduleReconnect(generation: number, delay?: number): void {
+    if (!this.#running || generation !== this.#generation || this.#retryTimer) return
+    this.#connection = undefined
+    const wait = delay ?? Math.min(15_000, 500 * 2 ** Math.min(this.#retryAttempt++, 5))
+    this.#retryTimer = setTimeout(() => {
+      this.#retryTimer = undefined
+      if (this.#running && generation === this.#generation) void this.#open(generation)
+    }, wait)
+    this.#retryTimer.unref()
+  }
+}

+ 239 - 5
src/server/routes.ts

@@ -31,6 +31,7 @@ import { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js
 import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
 import { UpstreamProfileIdentityService } from './profileIdentities.js'
+import { PushCoordinator } from './pushCoordinator.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -46,6 +47,7 @@ export interface RouteDependencies {
   upstreamSession: UpstreamServiceSession
   accountPairings: AccountLoginPairingStore
   profileIdentities: UpstreamProfileIdentityService
+  push: PushCoordinator
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -356,6 +358,83 @@ function json(ctx: Koa.Context, status: number, value: unknown): void {
   ctx.body = value
 }
 
+function pushRequest<T>(operation: () => T): T {
+  try {
+    return operation()
+  } catch (cause) {
+    const message = cause instanceof Error ? cause.message : '推送请求无效'
+    throw new HttpError(400, message, 'invalid_push_request')
+  }
+}
+
+function bestEffortAutoSubscribe(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  userID: string,
+  roomID: string,
+  baselineMessageSeq?: number,
+): void {
+  try {
+    dependencies.push.setGroupSubscription(userID, roomID, true, baselineMessageSeq)
+  } catch (cause) {
+    const detail = cause instanceof Error ? cause.message : String(cause)
+    ctx.app.emit('error', new Error(`团队消息已发送,但自动订阅推送失败:${detail}`), ctx)
+  }
+}
+
+function bestEffortRemoveUserPush(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  userID: string,
+): void {
+  try {
+    dependencies.push.removeUser(userID)
+  } catch (cause) {
+    const detail = cause instanceof Error ? cause.message : String(cause)
+    ctx.app.emit('error', new Error(`用户权限已更新,但推送状态清理失败:${detail}`), ctx)
+  }
+}
+
+function groupMessageSequence(response: UpstreamResponse): number | undefined {
+  try {
+    const payload = parseJson(response)
+    const data = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
+      ? payload.data as JsonObject : {}
+    const message = payload.message && typeof payload.message === 'object' && !Array.isArray(payload.message)
+      ? payload.message as JsonObject
+      : data.message && typeof data.message === 'object' && !Array.isArray(data.message)
+        ? data.message as JsonObject : {}
+    const value = Number(message.seq ?? message.sequence)
+    return Number.isSafeInteger(value) && value >= 0 ? value : undefined
+  } catch {
+    return undefined
+  }
+}
+
+async function latestGroupMessageSequence(
+  dependencies: RouteDependencies,
+  roomID: string,
+): Promise<number> {
+  const response = await dependencies.upstreamSession.request(
+    `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}/messages`,
+    { search: new URLSearchParams({ limit: '1' }) },
+  )
+  if (response.status === 404) throw new HttpError(404, '团队不存在', 'group_not_found')
+  const payload = requireSuccess(response)
+  const data = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
+    ? payload.data as JsonObject : {}
+  const rows = Array.isArray(payload.items) ? payload.items
+    : Array.isArray(payload.messages) ? payload.messages
+      : Array.isArray(data.items) ? data.items
+        : Array.isArray(data.messages) ? data.messages : []
+  const latest = rows.reduce((maximum, raw) => {
+    if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return maximum
+    const value = Number((raw as JsonObject).seq ?? (raw as JsonObject).sequence)
+    return Number.isSafeInteger(value) && value >= 0 ? Math.max(maximum, value) : maximum
+  }, 0)
+  return latest
+}
+
 function requestOrigin(ctx: Koa.Context, config: ServerConfig): string {
   const secure = ctx.secure || Boolean(config.tlsCert)
   return `${secure ? 'https' : 'http'}://${ctx.host}`
@@ -759,6 +838,7 @@ async function issueLease(
     credential,
     origin,
     accountKeys,
+    localUserID: (ctx.state.localUser as LocalUser | undefined)?.id,
     epoch: channel === 'groups' ? canonicalEpoch(request.epoch) : undefined,
     cursor: channel === 'groups' ? groupCursor(request.cursor) : undefined,
   })
@@ -933,6 +1013,112 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       ),
     })
   })
+  router.get('/api/push/v1/capabilities', (ctx) => {
+    dependencies.auth.require(ctx)
+    const capabilities = dependencies.push.capabilities()
+    json(ctx, 200, {
+      ...capabilities,
+      maximumSummaryCharacters: capabilities.maxSummaryCharacters,
+    })
+  })
+  router.put('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const request = body(ctx)
+    if (request.environment !== 'development' && request.environment !== 'production') {
+      throw new HttpError(400, 'environment must be development or production', 'invalid_push_request')
+    }
+    const environment = request.environment
+    const installation = pushRequest(() => dependencies.push.registerInstallation({
+      userId: user.id,
+      installationId: canonicalUUID(ctx.params.installationID, 'installation ID'),
+      clientAccountId: canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
+      deviceToken: typeof request.token === 'string'
+        ? request.token
+        : typeof request.deviceToken === 'string' ? request.deviceToken : '',
+      environment,
+      appVersion: typeof request.appVersion === 'string' ? request.appVersion : undefined,
+      authorizationVersion: dependencies.auth.pushAuthorizationVersion(user.id),
+    }))
+    json(ctx, 200, { installation })
+  })
+  router.delete('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const removed = pushRequest(() => dependencies.push.unregisterInstallation(
+      user.id,
+      canonicalUUID(ctx.params.installationID, 'installation ID'),
+      canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
+    ))
+    json(ctx, 200, { ok: true, removed })
+  })
+  router.post('/api/push/v1/installations/:installationID/accounts/:clientAccountID/badge-reset', (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const badge = pushRequest(() => dependencies.push.resetBadge(
+      user.id,
+      canonicalUUID(ctx.params.installationID, 'installation ID'),
+      canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
+    ))
+    json(ctx, 200, { badge })
+  })
+  router.get('/api/push/v1/group-subscriptions', (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const subscriptions = dependencies.push.listGroupSubscriptions(user.id)
+    json(ctx, 200, {
+      subscriptions,
+      roomIds: subscriptions.filter(item => item.enabled).map(item => item.roomId),
+    })
+  })
+  router.put('/api/push/v1/group-subscriptions/:roomID', async (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const request = body(ctx)
+    if (typeof request.enabled !== 'boolean') {
+      throw new HttpError(400, 'enabled must be a boolean', 'invalid_push_request')
+    }
+    const enabled = request.enabled
+    const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
+    const baseline = enabled ? await latestGroupMessageSequence(dependencies, roomID) : undefined
+    const subscription = pushRequest(() => dependencies.push.setGroupSubscription(
+      user.id,
+      roomID,
+      enabled,
+      baseline,
+    ))
+    json(ctx, 200, { subscription, enabled: subscription.enabled })
+  })
+  // Browser aliases stay inside the /api/app CSRF boundary. Native iOS uses
+  // the Gateway-compatible /api/push/v1 surface with the same account state.
+  router.get('/api/app/push/v1/capabilities', (ctx) => {
+    dependencies.auth.require(ctx)
+    const capabilities = dependencies.push.capabilities()
+    json(ctx, 200, {
+      ...capabilities,
+      maximumSummaryCharacters: capabilities.maxSummaryCharacters,
+    })
+  })
+  router.get('/api/app/push/v1/group-subscriptions', (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const subscriptions = dependencies.push.listGroupSubscriptions(user.id)
+    json(ctx, 200, {
+      subscriptions,
+      roomIds: subscriptions.filter(item => item.enabled).map(item => item.roomId),
+    })
+  })
+  router.put('/api/app/push/v1/group-subscriptions/:roomID', async (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const request = body(ctx)
+    if (typeof request.enabled !== 'boolean') {
+      throw new HttpError(400, 'enabled must be a boolean', 'invalid_push_request')
+    }
+    const enabled = request.enabled
+    const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
+    const baseline = enabled ? await latestGroupMessageSequence(dependencies, roomID) : undefined
+    const subscription = pushRequest(() => dependencies.push.setGroupSubscription(
+      user.id,
+      roomID,
+      enabled,
+      baseline,
+    ))
+    json(ctx, 200, { subscription, enabled: subscription.enabled })
+  })
   router.put('/api/account/credentials', (ctx) => {
     const request = body(ctx)
     const currentPassword = typeof request.currentPassword === 'string' ? request.currentPassword : ''
@@ -1285,14 +1471,21 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.patch('/api/app/admin/users/:userID', (ctx) => {
     const admin = dependencies.auth.requireAdmin(ctx)
     const request = body(ctx)
-    json(ctx, 200, dependencies.auth.updateUser(admin, canonicalUUID(ctx.params.userID, 'user ID'), {
+    const userID = canonicalUUID(ctx.params.userID, 'user ID')
+    const updated = dependencies.auth.updateUser(admin, userID, {
       enabled: typeof request.enabled === 'boolean' ? request.enabled : undefined,
       password: typeof request.password === 'string' ? request.password : undefined,
-    }))
+    })
+    if (request.enabled === false || typeof request.password === 'string') {
+      bestEffortRemoveUserPush(ctx, dependencies, userID)
+    }
+    json(ctx, 200, updated)
   })
   router.delete('/api/app/admin/users/:userID', (ctx) => {
     const admin = dependencies.auth.requireAdmin(ctx)
-    dependencies.auth.deleteUser(admin, canonicalUUID(ctx.params.userID, 'user ID'))
+    const userID = canonicalUUID(ctx.params.userID, 'user ID')
+    dependencies.auth.deleteUser(admin, userID)
+    bestEffortRemoveUserPush(ctx, dependencies, userID)
     json(ctx, 200, { ok: true })
   })
   router.put('/api/app/admin/upstream-credentials', async (ctx) => {
@@ -1315,6 +1508,13 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       ))
     })
   })
+  router.get('/api/app/system/push-status', (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, {
+      ...dependencies.push.status(),
+      topic: dependencies.push.capabilities().topic,
+    })
+  })
   router.post('/api/app/system/update/check', async (ctx) => {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
@@ -1680,6 +1880,8 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     })
   })
   router.post('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
+    const localUser = dependencies.auth.require(ctx)
+    const subscribedRoomID = canonicalUUID(ctx.params.roomID, 'room ID')
     const request = body(ctx)
     const uploadIds = Array.isArray(request.uploadIds)
       ? request.uploadIds.map((value) => String(value))
@@ -1701,12 +1903,31 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
         sendUpstreamResponse(ctx, response, jar)
         if (response.status >= 200 && response.status < 300) {
           dependencies.uploads.markReferenced(uploadIds, accountKey)
+          bestEffortAutoSubscribe(
+            ctx,
+            dependencies,
+            localUser.id,
+            subscribedRoomID,
+            groupMessageSequence(response),
+          )
         }
       })
       return
     }
-    await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
-      method: 'POST', requestBody: upstreamBody,
+    await withJar(ctx, async (jar) => {
+      const response = await dependencies.upstream.request(groupPath(ctx, '/messages'), jar, {
+        method: 'POST', body: upstreamBody,
+      })
+      sendUpstreamResponse(ctx, response, jar)
+      if (response.status >= 200 && response.status < 300) {
+        bestEffortAutoSubscribe(
+          ctx,
+          dependencies,
+          localUser.id,
+          subscribedRoomID,
+          groupMessageSequence(response),
+        )
+      }
     })
   })
   for (const action of ['approval', 'clarification'] as const) {
@@ -1783,6 +2004,19 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       maxResponseBytes: /\/plugins\/yaoyao\/v1\/nodes\/[0-9a-f-]{36}\/files$/.test(upstreamPath)
         ? 256 * 1_024 * 1_024 : undefined,
     })
+    const nativeGroupMessage = ctx.method === 'POST'
+      ? /^\/api\/plugins\/yaoyao\/v1\/rooms\/([0-9a-f-]{36})\/messages$/.exec(upstreamPath)
+      : null
+    if (nativeGroupMessage && response.status >= 200 && response.status < 300) {
+      const localUser = dependencies.auth.require(ctx)
+      bestEffortAutoSubscribe(
+        ctx,
+        dependencies,
+        localUser.id,
+        canonicalUUID(nativeGroupMessage[1]!, 'room ID'),
+        groupMessageSequence(response),
+      )
+    }
     sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
   })
 

+ 48 - 5
src/server/websocket.ts

@@ -5,8 +5,21 @@ import type { ServerConfig } from './config.js'
 import { HttpError } from './errors.js'
 import type { RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import type { NodePairingStore } from './pairing.js'
+import {
+  ChatPushRelayObserver,
+  type ChatNotificationResolver,
+  type PushEventCoordinator,
+} from './pushEvents.js'
 import { isAllowedHostHeader, isExactOrigin, requestAccountKey } from './security.js'
 
+type UpgradeRequest = Parameters<NonNullable<HttpServer['on']>>[1] extends never ? never : any
+
+export interface WebSocketPushDependencies {
+  coordinator: PushEventCoordinator
+  resolveGatewayUser?(request: UpgradeRequest): string | undefined
+  notificationResolver?: ChatNotificationResolver
+}
+
 const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
 const GROUP_MAX_PAYLOAD = 2 * 1_024 * 1_024
 const MAX_BUFFERED_BYTES = 8 * 1_024 * 1_024
@@ -421,6 +434,7 @@ function relaySocket(
   onFinished: () => void,
   groupAnchor?: { epoch: string; cursor: number },
   paired = false,
+  chatObserver?: ChatPushRelayObserver,
 ): void {
   const groupValidator = groupAnchor
     ? new GroupFrameValidator(groupAnchor.epoch, groupAnchor.cursor)
@@ -428,10 +442,12 @@ function relaySocket(
   let clientAlive = true
   let upstreamAlive = true
   let finished = false
+  let clientFrames = Promise.resolve()
   const finish = () => {
     if (finished) return
     finished = true
     clearInterval(heartbeat)
+    chatObserver?.disconnected()
     onFinished()
   }
   const heartbeat = setInterval(() => {
@@ -451,15 +467,18 @@ function relaySocket(
   upstream.on('pong', () => { upstreamAlive = true })
 
   client.on('message', (data, isBinary) => {
-    try {
+    clientFrames = clientFrames.then(() => {
       if (channel === 'groups') throw new HttpError(403, 'Group event stream is read-only')
       if (upstream.readyState !== WebSocket.OPEN) throw new HttpError(409, 'Hermes socket is not ready')
       if (upstream.bufferedAmount > MAX_BUFFERED_BYTES) throw new HttpError(429, 'Hermes socket is congested')
-      upstream.send(checkedChatFrame(data, isBinary, paired))
-    } catch (error) {
+      const frame = checkedChatFrame(data, isBinary, paired)
+      chatObserver?.observeClientFrame(frame)
+      if (upstream.readyState !== WebSocket.OPEN) throw new HttpError(409, 'Hermes socket is not ready')
+      upstream.send(frame)
+    }).catch(error => {
       const reason = error instanceof Error ? error.message : 'Invalid frame'
       closePair(client, upstream, 1008, reason)
-    }
+    })
   })
   upstream.on('message', (data, isBinary) => {
     const length = typeof data === 'string' ? Buffer.byteLength(data) : Buffer.byteLength(data as Uint8Array)
@@ -477,6 +496,7 @@ function relaySocket(
         return
       }
     }
+    if (channel === 'chat') chatObserver?.observeUpstreamFrame(data, isBinary)
     if (client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary })
   })
   client.on('close', (code, reason) => {
@@ -506,10 +526,11 @@ export function installWebSocketRelay(
   config: ServerConfig,
   leases: RealtimeLeaseStore,
   pairings: NodePairingStore,
+  push?: WebSocketPushDependencies,
 ): () => void {
   const socketServer = new WebSocketServer({ noServer: true, maxPayload: CHAT_MAX_PAYLOAD })
   const active = new Map<string, number>()
-  const onUpgrade = (request: Parameters<NonNullable<HttpServer['on']>>[1] extends never ? never : any, socket: Duplex, head: Buffer) => {
+  const onUpgrade = (request: UpgradeRequest, socket: Duplex, head: Buffer) => {
     let url: URL
     try {
       url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'invalid'}`)
@@ -549,6 +570,10 @@ export function installWebSocketRelay(
         return
       }
       active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
+      let gatewayUserID: string | undefined
+      if (gatewayChannel === 'chat' && push?.resolveGatewayUser) {
+        try { gatewayUserID = push.resolveGatewayUser(request) } catch { /* unowned relays are not push-tracked */ }
+      }
       socketServer.handleUpgrade(request, socket, head, (client) => {
         const upstream = new WebSocket(pairedUpstreamWebSocketURL(
           config, gatewayChannel, ticket, epoch ?? undefined, cursor,
@@ -568,6 +593,15 @@ export function installWebSocketRelay(
         upstream.once('open', () => relaySocket(
           client, upstream, gatewayChannel, release,
           gatewayChannel === 'groups' ? { epoch: epoch!, cursor } : undefined,
+          false,
+          gatewayUserID && push
+            ? new ChatPushRelayObserver(
+                push.coordinator,
+                { localUserID: gatewayUserID, source: 'gateway' },
+                Date.now,
+                push.notificationResolver,
+              )
+            : undefined,
         ))
         upstream.once('unexpected-response', release)
         upstream.once('error', () => {
@@ -737,6 +771,15 @@ export function installWebSocketRelay(
         channel,
         release,
         channel === 'groups' ? { epoch: lease.epoch!, cursor: lease.cursor ?? 0 } : undefined,
+        false,
+        channel === 'chat' && lease.localUserID && push
+          ? new ChatPushRelayObserver(
+              push.coordinator,
+              { localUserID: lease.localUserID, accountKey, source: 'web' },
+              Date.now,
+              push.notificationResolver,
+            )
+          : undefined,
       ))
       upstream.once('unexpected-response', () => {
         if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes rejected WebSocket')

+ 47 - 0
tests/client/pushProtocol.test.ts

@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  getGroupPushSubscriptions,
+  getPushCapabilities,
+  getPushSystemStatus,
+  setGroupPushSubscription,
+} from '@/api/push'
+import { clearApiSecurityContext, setApiCsrfToken } from '@/api/client'
+
+afterEach(() => {
+  clearApiSecurityContext()
+  vi.unstubAllGlobals()
+})
+
+describe('push client protocol', () => {
+  it('uses the versioned push API and protects subscription mutations', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      const payload = path === '/api/app/push/v1/capabilities'
+        ? { protocolVersion: 1, enabled: true, events: [], maximumSummaryCharacters: 180 }
+        : path === '/api/app/push/v1/group-subscriptions'
+          ? { subscriptions: [{ roomId: 'room-1', enabled: true }] }
+          : path === '/api/app/system/push-status'
+            ? { configured: true, healthy: true, registrationCount: 1, pendingCount: 0 }
+            : { subscription: { roomId: 'room-1', enabled: false } }
+      return new Response(JSON.stringify(payload), {
+        status: 200,
+        headers: { 'content-type': 'application/json' },
+      })
+    }))
+    setApiCsrfToken('csrf-push')
+
+    await expect(getPushCapabilities()).resolves.toMatchObject({ protocolVersion: 1, enabled: true })
+    await expect(getGroupPushSubscriptions()).resolves.toEqual(new Set(['room-1']))
+    await expect(setGroupPushSubscription('room-1', false)).resolves.toBe(false)
+    await expect(getPushSystemStatus()).resolves.toMatchObject({ configured: true, registrationCount: 1 })
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/push/v1/capabilities',
+      '/api/app/push/v1/group-subscriptions',
+      '/api/app/push/v1/group-subscriptions/room-1',
+      '/api/app/system/push-status',
+    ])
+    expect(new Headers(calls[2]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-push')
+  })
+})

+ 157 - 0
tests/server/apns.test.ts

@@ -0,0 +1,157 @@
+import { generateKeyPairSync, verify } from 'node:crypto'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http2'
+import type { AddressInfo } from 'node:net'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  APNS_DEVELOPMENT_ENDPOINT,
+  APNS_PRODUCTION_ENDPOINT,
+  APNsProvider,
+  NodeHttp2APNsTransport,
+  classifyAPNsResponse,
+  createAPNsProviderToken,
+  type APNsTransport,
+} from '../../src/server/apns.js'
+import type { APNsProviderConfig } from '../../src/server/config.js'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function keyFixture(): { config: APNsProviderConfig; publicKey: ReturnType<typeof generateKeyPairSync>['publicKey'] } {
+  const root = mkdtempSync(join(tmpdir(), 'yaoyao-apns-'))
+  roots.push(root)
+  const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
+  const keyFile = join(root, 'AuthKey_TEST.p8')
+  writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
+  return {
+    config: { keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes' },
+    publicKey,
+  }
+}
+
+describe('APNs provider token', () => {
+  it('creates a verifiable ES256 JWT with the Apple key and team identifiers', () => {
+    const { config, publicKey } = keyFixture()
+    const now = Date.UTC(2026, 7, 28, 10, 0, 0)
+    const token = createAPNsProviderToken(config, now)
+    const [header, claims, signature] = token.split('.') as [string, string, string]
+
+    expect(JSON.parse(Buffer.from(header, 'base64url').toString('utf8'))).toEqual({ alg: 'ES256', kid: config.keyId })
+    expect(JSON.parse(Buffer.from(claims, 'base64url').toString('utf8'))).toEqual({
+      iss: config.teamId,
+      iat: Math.floor(now / 1_000),
+    })
+    expect(verify(
+      'sha256',
+      Buffer.from(`${header}.${claims}`, 'ascii'),
+      { key: publicKey, dsaEncoding: 'ieee-p1363' },
+      Buffer.from(signature, 'base64url'),
+    )).toBe(true)
+  })
+})
+
+describe('APNs response classification', () => {
+  it('separates retry, registration cleanup, provider configuration, and terminal failures', () => {
+    expect(classifyAPNsResponse({ status: 200 }).disposition).toBe('success')
+    expect(classifyAPNsResponse({ status: 410, body: '{"reason":"Unregistered","timestamp":123}' })).toMatchObject({
+      disposition: 'unregister', reason: 'Unregistered', timestamp: 123,
+    })
+    expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadDeviceToken"}' }).disposition).toBe('unregister')
+    expect(classifyAPNsResponse({ status: 403, body: '{"reason":"InvalidProviderToken"}' }).disposition).toBe('configuration')
+    expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadCollapseId"}' }).disposition).toBe('failed')
+    expect(classifyAPNsResponse({ status: 400, body: '{"reason":"DeviceTokenNotForTopic"}' }).disposition).toBe('unregister')
+    expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadTopic"}' }).disposition).toBe('configuration')
+    expect(classifyAPNsResponse({ status: 429, headers: { 'retry-after': '3' } })).toMatchObject({
+      disposition: 'retry', retryAfterMs: 3_000,
+    })
+    expect(classifyAPNsResponse({ status: 503 }).disposition).toBe('retry')
+  })
+})
+
+describe('APNsProvider', () => {
+  it('sends alert payloads to the selected environment without assuming a fixed token length', async () => {
+    const { config } = keyFixture()
+    const calls: Array<{ endpoint: URL; headers: Record<string, unknown>; body: Uint8Array }> = []
+    const transport: APNsTransport = {
+      async send(endpoint, headers, body) {
+        calls.push({ endpoint, headers: headers as Record<string, unknown>, body })
+        return { status: 200, headers: { 'apns-id': 'returned-id' } }
+      },
+    }
+    const provider = new APNsProvider(config, { transport, now: () => 1_777_777_777_000 })
+
+    await expect(provider.send({
+      deviceToken: 'ab'.repeat(16),
+      environment: 'development',
+      payload: { aps: { alert: '完成' } },
+      collapseId: 'chat-1',
+    })).resolves.toMatchObject({ disposition: 'success', apnsId: 'returned-id' })
+    await provider.send({
+      deviceToken: 'cd'.repeat(48),
+      environment: 'production',
+      payload: { aps: { alert: '完成' } },
+    })
+
+    expect(calls[0]!.endpoint.href).toBe(APNS_DEVELOPMENT_ENDPOINT.href)
+    expect(calls[1]!.endpoint.href).toBe(APNS_PRODUCTION_ENDPOINT.href)
+    expect(calls[0]!.headers[':path']).toBe(`/3/device/${'ab'.repeat(16)}`)
+    expect(calls[0]!.headers['apns-topic']).toBe(config.topic)
+    expect(calls[0]!.headers['apns-push-type']).toBe('alert')
+    expect(calls[0]!.headers.authorization).toMatch(/^bearer /)
+    expect(JSON.parse(Buffer.from(calls[0]!.body).toString('utf8'))).toEqual({ aps: { alert: '完成' } })
+  })
+
+  it('does not contact APNs for malformed or oversized requests and retries transport failures', async () => {
+    const { config } = keyFixture()
+    let calls = 0
+    const provider = new APNsProvider(config, {
+      transport: {
+        async send() {
+          calls += 1
+          throw new Error('network unavailable')
+        },
+      },
+    })
+
+    await expect(provider.send({
+      deviceToken: 'not-a-token', environment: 'production', payload: { aps: {} },
+    })).resolves.toMatchObject({ disposition: 'unregister', reason: 'BadDeviceToken' })
+    await expect(provider.send({
+      deviceToken: 'ef'.repeat(16), environment: 'production', payload: { value: 'x'.repeat(5_000) },
+    })).resolves.toMatchObject({ disposition: 'failed', reason: 'PayloadTooLarge' })
+    await expect(provider.send({
+      deviceToken: 'ef'.repeat(16), environment: 'production', payload: { aps: {} },
+    })).resolves.toMatchObject({ disposition: 'retry', reason: 'network unavailable' })
+    expect(calls).toBe(1)
+  })
+})
+
+describe('Node HTTP/2 APNs transport', () => {
+  it('times out a half-open stream instead of blocking the outbox forever', async () => {
+    const server = createServer()
+    server.on('stream', () => {
+      // Intentionally leave the stream open without response headers.
+    })
+    await new Promise<void>((resolve, reject) => {
+      server.once('error', reject)
+      server.listen(0, '127.0.0.1', resolve)
+    })
+    const port = (server.address() as AddressInfo).port
+    const transport = new NodeHttp2APNsTransport(25)
+    try {
+      await expect(transport.send(
+        new URL(`http://127.0.0.1:${port}`),
+        { ':method': 'POST', ':path': '/3/device/token' },
+        Buffer.from('{}'),
+      )).rejects.toThrow(/timed out/)
+    } finally {
+      transport.close()
+      await new Promise<void>(resolve => server.close(() => resolve()))
+    }
+  })
+})

+ 5 - 2
tests/server/leases.test.ts

@@ -10,6 +10,7 @@ describe('RealtimeLeaseStore', () => {
       credential: { name: 'ticket', value: 'upstream-secret' },
       origin: 'http://127.0.0.1:8800',
       accountKeys: ['account-a'],
+      localUserID: 'local-user-a',
     })
     expect(() => store.consume(
       lease.id,
@@ -17,12 +18,14 @@ describe('RealtimeLeaseStore', () => {
       'http://127.0.0.1:8800',
       'account-a',
     )).toThrow(/invalid or expired/)
-    expect(store.consume(
+    const consumed = store.consume(
       lease.id,
       'chat',
       'http://127.0.0.1:8800',
       'account-a',
-    ).credential.value).toBe('upstream-secret')
+    )
+    expect(consumed.credential.value).toBe('upstream-secret')
+    expect(consumed.localUserID).toBe('local-user-a')
     expect(() => store.consume(
       lease.id,
       'chat',

+ 343 - 0
tests/server/pushCoordinator.test.ts

@@ -0,0 +1,343 @@
+import { generateKeyPairSync } from 'node:crypto'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
+import { loadServerConfig, type APNsProviderConfig } from '../../src/server/config.js'
+import {
+  PushCoordinator,
+  type PushSender,
+} from '../../src/server/pushCoordinator.js'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function root(): string {
+  const value = mkdtempSync(join(tmpdir(), 'yaoyao-push-'))
+  roots.push(value)
+  return value
+}
+
+const apns: APNsProviderConfig = {
+  keyFile: '/private/not-read-when-provider-is-injected.p8',
+  keyId: 'KEY1234567',
+  teamId: 'TEAM123456',
+  topic: 'cn.samien.yaoyao.hermes',
+}
+
+class FakeSender implements PushSender {
+  readonly requests: APNsRequest[] = []
+  readonly results: APNsSendResult[] = []
+  closed = false
+
+  async send(request: APNsRequest): Promise<APNsSendResult> {
+    this.requests.push(request)
+    return this.results.shift() ?? { disposition: 'success', status: 200 }
+  }
+
+  close(): void { this.closed = true }
+}
+
+function register(coordinator: PushCoordinator, suffix = '1') {
+  return coordinator.registerInstallation({
+    userId: 'user-a',
+    installationId: `phone-${suffix}`,
+    clientAccountId: 'account-a',
+    deviceToken: 'ab'.repeat(16 + Number(suffix)),
+    environment: 'development',
+    appVersion: '1.2.3',
+  })
+}
+
+describe('push configuration', () => {
+  it('keeps APNs optional and reports partial configuration without preventing startup', () => {
+    expect(loadServerConfig({}).apns).toBeUndefined()
+    const partial = loadServerConfig({ HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567' })
+    expect(partial.apns).toBeUndefined()
+    expect(partial.apnsConfigurationError).toMatch(/must be set together/)
+  })
+
+  it('loads a complete provider configuration and the default topic', () => {
+    const directory = root()
+    const keyFile = join(directory, 'AuthKey.p8')
+    const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
+    writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
+    const config = loadServerConfig({
+      HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
+      HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
+      HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
+    })
+    expect(config.apns).toEqual({
+      keyFile,
+      keyId: 'KEY1234567',
+      teamId: 'TEAM123456',
+      topic: 'cn.samien.yaoyao.hermes',
+    })
+  })
+
+  it('disables an unreadable provider key as a capability error', () => {
+    const keyFile = join(root(), 'AuthKey.p8')
+    writeFileSync(keyFile, 'not a private key')
+    const config = loadServerConfig({
+      HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
+      HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
+      HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
+    })
+    expect(config.apns).toBeUndefined()
+    expect(config.apnsConfigurationError).toMatch(/ES256 private key/)
+  })
+})
+
+describe('PushCoordinator durable state and delivery', () => {
+  it('persists private registrations, subscriptions, outbox delivery, and badge state', async () => {
+    const home = root()
+    const sender = new FakeSender()
+    const coordinator = new PushCoordinator({ home, apns, provider: sender, autoFlush: false })
+    expect(coordinator.capabilities()).toMatchObject({
+      protocolVersion: 1, enabled: true, topic: apns.topic, maxSummaryCharacters: 180,
+    })
+    expect(register(coordinator)).not.toHaveProperty('deviceToken')
+    expect(coordinator.setGroupSubscription('user-a', 'room-1', true)).toMatchObject({ roomId: 'room-1', enabled: true })
+    expect(coordinator.isGroupSubscribed('user-a', 'room-1')).toBe(true)
+    expect(coordinator.subscribedUserIds('room-1')).toEqual(['user-a'])
+
+    expect(coordinator.enqueue({
+      eventId: 'event-1',
+      userId: 'user-a',
+      kind: 'group.message.completed',
+      title: '任务完成',
+      body: '好'.repeat(250),
+      roomId: 'room-1',
+      threadId: 'topic-1',
+      collapseId: 'message-1',
+      data: { roomId: 'room-1', aps: 'untrusted', version: 99 },
+    })).toBe(1)
+    expect(coordinator.enqueue({
+      eventId: 'event-1', userId: 'user-a', kind: 'group.message.completed', title: '重复', body: '重复',
+    })).toBe(0)
+    expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1, topic: apns.topic })
+
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
+    expect(sender.requests).toHaveLength(1)
+    expect(sender.requests[0]!.payload).toMatchObject({
+      version: 1,
+      eventId: 'event-1',
+      kind: 'group.message.completed',
+      clientAccountId: 'account-a',
+      aps: { alert: { title: '任务完成' }, badge: 1, 'thread-id': 'topic-1' },
+    })
+    expect(Array.from(((sender.requests[0]!.payload.aps as { alert: { body: string } }).alert.body))).toHaveLength(180)
+    expect(sender.requests[0]!.payload).not.toHaveProperty('userId')
+    expect(coordinator.status()).toMatchObject({ healthy: true, pendingCount: 0 })
+
+    const statePath = join(home, 'push', 'state.json')
+    expect(statSync(statePath).mode & 0o777).toBe(0o600)
+    const stored = readFileSync(statePath, 'utf8')
+    expect(stored).toContain('ab'.repeat(17))
+    expect(stored).not.toContain(apns.keyFile)
+    coordinator.close()
+
+    const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+    expect(restored.status()).toMatchObject({ registrationCount: 1, subscriptionCount: 1 })
+    expect(restored.listGroupSubscriptions('user-a')).toEqual([
+      expect.objectContaining({ roomId: 'room-1', enabled: true }),
+    ])
+    restored.close()
+  })
+
+  it('retries transient failures exponentially and removes invalid registrations', async () => {
+    const home = root()
+    let now = 1_000_000
+    const sender = new FakeSender()
+    sender.results.push(
+      { disposition: 'retry', status: 503, reason: 'Shutdown' },
+      { disposition: 'unregister', status: 410, reason: 'Unregistered' },
+    )
+    const coordinator = new PushCoordinator({
+      home, apns, provider: sender, autoFlush: false, now: () => now, baseRetryMilliseconds: 100,
+    })
+    register(coordinator)
+    coordinator.enqueue({ eventId: 'retry-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
+
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ retried: 1 })
+    expect(coordinator.status()).toMatchObject({ pendingCount: 1, healthy: false })
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 0, retried: 0 })
+    expect(sender.requests).toHaveLength(1)
+    now += 100
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ removedRegistrations: 1 })
+    expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
+    coordinator.close()
+  })
+
+  it('does not delete a token that rotated while an APNs request was in flight', async () => {
+    let release!: (result: APNsSendResult) => void
+    let started!: () => void
+    const began = new Promise<void>(resolve => { started = resolve })
+    const first = new Promise<APNsSendResult>(resolve => { release = resolve })
+    const requests: APNsRequest[] = []
+    const sender: PushSender = {
+      send: async request => {
+        requests.push(request)
+        if (requests.length === 1) {
+          started()
+          return first
+        }
+        return { disposition: 'success', status: 200 }
+      },
+    }
+    const coordinator = new PushCoordinator({ home: root(), apns, provider: sender, autoFlush: false })
+    register(coordinator)
+    coordinator.enqueue({ eventId: 'rotated-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
+
+    const flushing = coordinator.flushDue()
+    await began
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-1', clientAccountId: 'account-a',
+      deviceToken: 'cd'.repeat(32), environment: 'development', appVersion: '1.2.4',
+    })
+    release({ disposition: 'unregister', status: 410, reason: 'Unregistered' })
+    await expect(flushing).resolves.toMatchObject({ retried: 1, removedRegistrations: 0 })
+    expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
+    expect(requests[0]!.deviceToken).not.toBe(requests[1]!.deviceToken)
+    expect(requests[1]!.deviceToken).toBe('cd'.repeat(32))
+    coordinator.close()
+  })
+
+  it('fails closed when a user is disabled or their authentication version changes', async () => {
+    let active = true
+    let authVersion = 1
+    const sender = new FakeSender()
+    const coordinator = new PushCoordinator({
+      home: root(), apns, provider: sender, autoFlush: false,
+      isUserActive: () => active,
+      userAuthorizationVersion: () => authVersion,
+    })
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
+      deviceToken: 'ab'.repeat(32), environment: 'development', authorizationVersion: 1,
+    })
+    coordinator.enqueue({ eventId: 'version-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
+    authVersion = 2
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ failed: 1, delivered: 0 })
+    expect(sender.requests).toHaveLength(0)
+    expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
+
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
+      deviceToken: 'cd'.repeat(32), environment: 'development', authorizationVersion: 2,
+    })
+    active = false
+    expect(coordinator.enqueue({
+      eventId: 'disabled', userId: 'user-a', kind: 'chat.completed', title: '不应发送', body: '正文',
+    })).toBe(0)
+    coordinator.close()
+  })
+
+  it('cancels pending team notifications on unsubscribe and removes all user-owned state', () => {
+    const home = root()
+    const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+    register(coordinator)
+    coordinator.setGroupSubscription('user-a', 'room-1', true)
+    expect(coordinator.enqueueGroupEvent({
+      eventID: 'group-1', localUserID: 'user-a', kind: 'group.message.completed',
+      title: '团队消息', body: '正文', collapseID: 'group-1', data: {}, roomID: 'room-1',
+    })).toBe('enqueued')
+    coordinator.setGroupSubscription('user-a', 'room-1', false)
+    expect(coordinator.status().pendingCount).toBe(0)
+
+    coordinator.setGroupSubscription('user-a', 'room-2', true)
+    const removed = coordinator.removeUser('user-a')
+    expect(removed).toMatchObject({ registrations: 1, subscriptions: 1 })
+    expect(coordinator.status()).toMatchObject({ registrationCount: 0, subscriptionCount: 0 })
+    expect(coordinator.listGroupSubscriptions('user-a')).toEqual([])
+    coordinator.close()
+  })
+
+  it('persists the global group cursor and resumable chat jobs in the same state file', () => {
+    const home = root()
+    const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+    expect(coordinator.resetGroupCursor({ epoch: 'epoch-1', cursor: 10, reason: 'initial' })).toMatchObject({
+      epoch: 'epoch-1', cursor: 10,
+    })
+    coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 11 })
+    expect(() => coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 9 })).toThrow(/backwards/)
+    coordinator.saveChatJob({
+      id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
+      storedSessionID: 'stored-1', requestID: 'request-1', queued: false, phase: 'watching',
+      submittedAt: 1, expiresAt: Date.now() + 60_000,
+    })
+    coordinator.close()
+
+    const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+    expect(restored.groupWatchAnchor()).toMatchObject({ epoch: 'epoch-1', cursor: 11 })
+    expect(restored.pendingChatJobs()).toEqual([expect.objectContaining({ id: 'job-1', localUserID: 'user-a' })])
+    restored.completeChatJob('job-1')
+    expect(restored.pendingChatJobs()).toEqual([])
+    restored.close()
+  })
+
+  it('fails closed when more than one local user has written the same stored session', () => {
+    const coordinator = new PushCoordinator({ home: root(), apns, provider: new FakeSender(), autoFlush: false })
+    const job = (id: string, localUserID: string) => ({
+      id, localUserID, profile: 'default', runtimeSessionID: `runtime-${id}`, storedSessionID: 'shared',
+      requestID: `request-${id}`, queued: false, phase: 'accepted' as const,
+      submittedAt: 1, expiresAt: Date.now() + 60_000,
+    })
+    const first = job('a', 'user-a')
+    coordinator.saveChatJob(first)
+    expect(coordinator.chatJobRecoveryAllowed(first)).toBe(true)
+    const second = job('b', 'user-b')
+    coordinator.saveChatJob(second)
+    expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
+    expect(coordinator.chatJobRecoveryAllowed(second)).toBe(false)
+    coordinator.completeChatJob('b')
+    expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
+    coordinator.close()
+  })
+
+  it('keeps prompt correlation private and stable across restart', () => {
+    const home = root()
+    const first = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+    const digest = first.promptDigest('user-a', '继续执行')
+    expect(digest).not.toContain('继续执行')
+    expect(first.promptDigest('user-b', '继续执行')).not.toBe(digest)
+    first.close()
+
+    const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+    expect(restored.promptDigest('user-a', '继续执行')).toBe(digest)
+    expect(statSync(join(home, 'push', 'correlation.key')).mode & 0o777).toBe(0o600)
+    restored.close()
+  })
+
+  it('automatically flushes newly queued work and closes the injected provider', async () => {
+    const sender = new FakeSender()
+    const coordinator = new PushCoordinator({ home: root(), apns, provider: sender })
+    register(coordinator)
+    coordinator.enqueue({ eventId: 'auto-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
+    await expect.poll(() => sender.requests.length).toBe(1)
+    expect(coordinator.status().pendingCount).toBe(0)
+    coordinator.close()
+    expect(sender.closed).toBe(true)
+  })
+
+  it('protects and preserves a malformed existing state file instead of overwriting it', () => {
+    const home = root()
+    const directory = join(home, 'push')
+    const statePath = join(directory, 'state.json')
+    mkdirSync(directory)
+    writeFileSync(statePath, '{malformed', { mode: 0o644, flag: 'w' })
+    const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
+
+    expect(coordinator.capabilities()).toMatchObject({ enabled: false })
+    expect(coordinator.status().lastError).toMatch(/避免覆盖/)
+    expect(() => register(coordinator)).toThrow(/推送状态文件无法读取/)
+    expect(readFileSync(statePath, 'utf8')).toBe('{malformed')
+    expect(statSync(statePath).mode & 0o777).toBe(0o600)
+    coordinator.close()
+  })
+})

+ 176 - 0
tests/server/pushEventAdapter.test.ts

@@ -0,0 +1,176 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { APNsRequest } from '../../src/server/apns.js'
+import type { APNsProviderConfig } from '../../src/server/config.js'
+import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
+import { PushCoordinator } from '../../src/server/pushCoordinator.js'
+import { PushCoordinatorEventAdapter } from '../../src/server/pushEventAdapter.js'
+
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+describe('push event reset reconciliation', () => {
+  it('recovers only post-subscription terminal messages and pending interactions before advancing cursor', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-reconcile-'))
+    roots.push(home)
+    let now = 1_000_000
+    const requests: APNsRequest[] = []
+    const apns: APNsProviderConfig = {
+      keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
+    }
+    const push = new PushCoordinator({
+      home, apns, autoFlush: false, now: () => now,
+      provider: { send: async request => { requests.push(request); return { disposition: 'success', status: 200 } } },
+    })
+    push.registerInstallation({
+      userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
+      deviceToken: 'ab'.repeat(32), environment: 'development',
+    })
+    push.setGroupSubscription('user-a', 'room-1', true, 1)
+    now += 10_000
+
+    const upstreamSession = {
+      request: async (path: string) => {
+        if (path.endsWith('/messages')) {
+          return {
+            status: 200,
+            body: Buffer.from(JSON.stringify({ items: [
+              { id: 'old-message', seq: 1, senderKind: 'agent', senderName: '旧 Agent', status: 'completed', content: '旧消息', updatedAt: 900 },
+              { id: 'new-message', seq: 2, topicId: 'topic-1', senderKind: 'agent', senderName: '新 Agent', status: 'completed', content: '断线期间回复', updatedAt: 1_005 },
+            ] })),
+          }
+        }
+        return {
+          status: 200,
+          body: Buffer.from(JSON.stringify({
+            room: {
+              id: 'room-1', name: '项目团队',
+              pendingInteractions: [{
+                id: 'interaction-1', kind: 'clarification', status: 'pending', topicId: 'topic-1',
+                updatedAt: 1_006, payload: { question: '请选择方案' },
+              }],
+            },
+          })),
+        }
+      },
+    } as unknown as UpstreamServiceSession
+    const adapter = new PushCoordinatorEventAdapter(push, upstreamSession)
+
+    await expect(adapter.resetGroupCursor({
+      epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 42, reason: 'cursor_expired',
+    })).resolves.toEqual({ epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 42 })
+    expect(push.status().pendingCount).toBe(2)
+    await expect(push.flushDue()).resolves.toMatchObject({ delivered: 2 })
+    expect(requests).toHaveLength(2)
+    expect(requests.map(request => request.payload)).toEqual(expect.arrayContaining([
+      expect.objectContaining({ eventId: 'group-message:new-message:completed', roomId: 'room-1', messageId: 'new-message' }),
+      expect.objectContaining({ eventId: 'group-interaction:interaction-1', roomId: 'room-1', interactionId: 'interaction-1' }),
+    ]))
+    expect(JSON.stringify(requests)).not.toContain('old-message')
+
+    await adapter.resetGroupCursor({
+      epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 43, reason: 'cursor_expired',
+    })
+    expect(push.status().pendingCount).toBe(0)
+    push.close()
+  })
+
+  it('ignores a lagged live event older than the current subscription', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-lagged-'))
+    roots.push(home)
+    let now = 2_000_000
+    const apns: APNsProviderConfig = {
+      keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
+    }
+    const push = new PushCoordinator({
+      home, apns, autoFlush: false, now: () => now,
+      provider: { send: async () => ({ disposition: 'success', status: 200 }) },
+    })
+    push.registerInstallation({
+      userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
+      deviceToken: 'ab'.repeat(32), environment: 'development',
+    })
+    push.setGroupSubscription('user-a', 'room-1', true)
+    const adapter = new PushCoordinatorEventAdapter(push, {} as UpstreamServiceSession)
+
+    expect(adapter.enqueueNotification({
+      eventID: 'group-message:old:completed', localUserID: 'user-a', kind: 'group.message.completed',
+      title: '旧消息', body: '旧消息', collapseID: 'old', data: {}, roomID: 'room-1',
+      epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 1, occurredAt: now - 10_000,
+    })).toBe('ignored')
+    expect(push.status().pendingCount).toBe(0)
+    now += 10_000
+    expect(adapter.enqueueNotification({
+      eventID: 'group-message:new:completed', localUserID: 'user-a', kind: 'group.message.completed',
+      title: '新消息', body: '新消息', collapseID: 'new', data: {}, roomID: 'room-1',
+      epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 2, occurredAt: now,
+    })).toBe('enqueued')
+    push.close()
+  })
+
+  it('drops a deleted room subscription instead of wedging the global reset', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-deleted-room-'))
+    roots.push(home)
+    const apns: APNsProviderConfig = {
+      keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
+    }
+    const push = new PushCoordinator({
+      home, apns, autoFlush: false,
+      provider: { send: async () => ({ disposition: 'success', status: 200 }) },
+    })
+    push.setGroupSubscription('user-a', 'deleted-room', true, 9)
+    const upstream = {
+      request: async () => ({ status: 404, body: Buffer.from('{}') }),
+    } as unknown as UpstreamServiceSession
+    const adapter = new PushCoordinatorEventAdapter(push, upstream)
+
+    await expect(adapter.resetGroupCursor({
+      epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 10, reason: 'epoch_mismatch',
+    })).resolves.toMatchObject({ cursor: 10 })
+    expect(push.listGroupSubscriptions('user-a')).toEqual([])
+    push.close()
+  })
+
+  it('walks backward from the latest page when a room baseline is zero', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-zero-baseline-'))
+    roots.push(home)
+    const apns: APNsProviderConfig = {
+      keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
+    }
+    const push = new PushCoordinator({
+      home, apns, autoFlush: false,
+      provider: { send: async () => ({ disposition: 'success', status: 200 }) },
+    })
+    push.setGroupSubscription('user-a', 'room-zero', true, 0)
+    const searches: string[] = []
+    const message = (seq: number) => ({
+      id: `message-${seq}`, seq, senderKind: 'agent', senderName: 'Agent', status: 'completed', content: `消息 ${seq}`,
+    })
+    const upstream = {
+      request: async (path: string, options?: { search?: URLSearchParams }) => {
+        if (!path.endsWith('/messages')) {
+          return { status: 200, body: Buffer.from('{"room":{"id":"room-zero","name":"零基线团队"}}') }
+        }
+        const search = options?.search?.toString() ?? ''
+        searches.push(search)
+        const before = Number(options?.search?.get('beforeSeq') ?? 0)
+        const items = before === 51
+          ? Array.from({ length: 50 }, (_, index) => message(index + 1))
+          : Array.from({ length: 100 }, (_, index) => message(index + 51))
+        return { status: 200, body: Buffer.from(JSON.stringify({ items })) }
+      },
+    } as unknown as UpstreamServiceSession
+    const adapter = new PushCoordinatorEventAdapter(push, upstream)
+
+    await adapter.resetGroupCursor({
+      epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 150, reason: 'cursor_expired',
+    })
+    expect(searches).toEqual(['limit=100', 'limit=100&beforeSeq=51'])
+    expect(push.listGroupSubscriptions('user-a')[0]?.lastMessageSeq).toBe(150)
+    push.close()
+  })
+})

+ 699 - 0
tests/server/pushEvents.test.ts

@@ -0,0 +1,699 @@
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import WebSocket, { WebSocketServer } from 'ws'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ServerConfig } from '../../src/server/config.js'
+import { UpstreamServiceSession } from '../../src/server/localAuth.js'
+import {
+  ChatPushJobManager,
+  ChatPushRelayObserver,
+  GroupPushEventProcessor,
+  HermesChatNotificationResolver,
+  HermesChatPushJobWatcher,
+  type ChatPushJob,
+  type ChatPushObservation,
+  type GroupPushCandidate,
+  type GroupWatchAnchor,
+  type GroupWatchReset,
+  type PushEventCoordinator,
+  type PushNotificationCandidate,
+} from '../../src/server/pushEvents.js'
+import { UpstreamClient } from '../../src/server/upstream.js'
+
+const epoch = '123e4567-e89b-42d3-a456-426614174000'
+const closers: Array<() => Promise<void>> = []
+
+afterEach(async () => {
+  for (const close of closers.splice(0).reverse()) await close()
+})
+
+function listen(server: Server): Promise<number> {
+  return new Promise((resolve, reject) => {
+    server.once('error', reject)
+    server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
+  })
+}
+
+function closeServer(server: Server): Promise<void> {
+  return new Promise(resolve => server.close(() => resolve()))
+}
+
+class FakeCoordinator implements PushEventCoordinator {
+  observations: ChatPushObservation[] = []
+  jobs = new Map<string, ChatPushJob>()
+  notifications: PushNotificationCandidate[] = []
+  anchor?: GroupWatchAnchor
+  subscribers = new Map<string, string[]>()
+  failEnqueue = false
+
+  promptDigest(localUserID: string, prompt: string): string { return `${localUserID}:${prompt}` }
+  canRecoverChatJob(job: ChatPushJob): boolean {
+    const owners = new Set([...this.jobs.values()]
+      .filter(candidate => candidate.storedSessionID === job.storedSessionID)
+      .map(candidate => candidate.localUserID))
+    return owners.size === 1 && owners.has(job.localUserID)
+  }
+
+  observeChat(observation: ChatPushObservation): void { this.observations.push(observation) }
+  saveChatJob(job: ChatPushJob): void { this.jobs.set(job.id, { ...job }) }
+  completeChatJob(jobID: string): void { this.jobs.delete(jobID) }
+  pendingChatJobs(): readonly ChatPushJob[] { return [...this.jobs.values()] }
+  enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' {
+    if (this.failEnqueue) throw new Error('outbox unavailable')
+    if (this.notifications.some(item => item.localUserID === candidate.localUserID && item.eventID === candidate.eventID)) {
+      return 'duplicate'
+    }
+    this.notifications.push(candidate)
+    return 'enqueued'
+  }
+  groupWatchAnchor(): GroupWatchAnchor | undefined { return this.anchor && { ...this.anchor } }
+  groupSubscribers(roomID: string): readonly string[] { return this.subscribers.get(roomID) ?? [] }
+  advanceGroupCursor(anchor: GroupWatchAnchor): void { this.anchor = { ...anchor } }
+  resetGroupCursor(reset: GroupWatchReset): GroupWatchAnchor {
+    this.anchor = { epoch: reset.epoch, cursor: reset.cursor }
+    return this.anchor
+  }
+}
+
+describe('push event bridge', () => {
+  it('persists an owned chat job and enqueues a terminal notification', async () => {
+    const coordinator = new FakeCoordinator()
+    const observer = new ChatPushRelayObserver(
+      coordinator,
+      { localUserID: 'user-a', accountKey: 'account-a', source: 'web', connectionID: 'connection-a' },
+      () => 1_000,
+      { resolveTerminal: async () => ({ title: '权威会话标题', body: '权威最终正文', confirmed: true }) },
+    )
+    await observer.observeClientFrame(JSON.stringify({
+      jsonrpc: '2.0', id: 'resume', method: 'session.resume',
+      params: { profile: 'default', session_id: 'stored-1' },
+    }))
+    observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+      jsonrpc: '2.0', id: 'resume', result: { session_id: 'runtime-1', stored_session_id: 'stored-1' },
+    })), false)
+    await observer.observeClientFrame(JSON.stringify({
+      jsonrpc: '2.0', id: 'prompt', method: 'prompt.submit',
+      params: { session_id: 'runtime-1', text: '执行任务' },
+    }))
+    observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+      jsonrpc: '2.0', id: 'prompt', result: { status: 'accepted' },
+    })), false)
+    observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+      jsonrpc: '2.0', method: 'event', params: {
+        type: 'message.complete', session_id: 'runtime-1', profile: 'default',
+        payload: { text: '中继正文', status: 'complete' },
+      },
+    })), false)
+    await observer.flush()
+
+    expect(coordinator.jobs.size).toBe(0)
+    expect(coordinator.notifications).toEqual([
+      expect.objectContaining({
+        localUserID: 'user-a', kind: 'chat.completed', title: '权威会话标题', body: '权威最终正文',
+        sessionID: 'stored-1', profile: 'default',
+      }),
+    ])
+    expect(coordinator.observations.map(item => item.type)).toEqual([
+      'chat.session_opened', 'chat.prompt', 'chat.prompt', 'chat.rpc_event',
+    ])
+  })
+
+  it('keeps a chat job after disconnect and emits approval only once through durable dedupe', async () => {
+    const coordinator = new FakeCoordinator()
+    const observer = new ChatPushRelayObserver(
+      coordinator,
+      { localUserID: 'user-a', source: 'gateway' },
+      () => 2_000,
+      { resolveTerminal: async () => ({ correlated: true }) },
+    )
+    await observer.observeClientFrame(JSON.stringify({
+      id: 'resume', method: 'session.resume', params: { profile: 'default', session_id: 'stored-2' },
+    }))
+    observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+      id: 'resume', result: { session_id: 'runtime-2', stored_session_id: 'stored-2' },
+    })), false)
+    await observer.observeClientFrame(JSON.stringify({
+      id: 'prompt', method: 'prompt.submit', params: { session_id: 'runtime-2', text: '执行' },
+    }))
+    observer.observeUpstreamFrame(Buffer.from(JSON.stringify({ id: 'prompt', result: { status: 'accepted' } })), false)
+    const approval = Buffer.from(JSON.stringify({
+      method: 'event', params: {
+        type: 'approval.requested', session_id: 'runtime-2',
+        payload: { request_id: 'approval-1', message: '允许执行?' },
+      },
+    }))
+    observer.observeUpstreamFrame(approval, false)
+    observer.observeUpstreamFrame(approval, false)
+    observer.disconnected()
+    await observer.flush()
+
+    expect(coordinator.jobs.size).toBe(1)
+    expect(coordinator.notifications).toHaveLength(1)
+    expect(coordinator.notifications[0]).toMatchObject({
+      kind: 'chat.approval.requested', requestID: 'approval-1', body: '允许执行?',
+    })
+    expect(coordinator.observations.at(-1)).toMatchObject({ type: 'chat.disconnected' })
+  })
+
+  it('persists a submitted unknown-receipt job with an authoritative pre-submit baseline', async () => {
+    const coordinator = new FakeCoordinator()
+    const observer = new ChatPushRelayObserver(
+      coordinator,
+      { localUserID: 'user-a', source: 'gateway' },
+      () => 5_000,
+      {
+        captureBaseline: async () => ({
+          messageID: 'assistant-before', sequence: 8, total: 9, assistantCount: 4, lastRowSequence: 8,
+        }),
+        resolveTerminal: async () => ({}),
+      },
+    )
+    await observer.observeClientFrame(JSON.stringify({
+      id: 'resume', method: 'session.resume', params: { profile: 'default', session_id: 'stored-3' },
+    }))
+    observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+      id: 'resume', result: { session_id: 'runtime-3', stored_session_id: 'stored-3' },
+    })), false)
+    await observer.observeClientFrame(JSON.stringify({
+      id: 'prompt-unknown', method: 'prompt.submit', params: { session_id: 'runtime-3', text: '可能已收到' },
+    }))
+    observer.disconnected()
+    await observer.flush()
+
+    expect([...coordinator.jobs.values()]).toEqual([
+      expect.objectContaining({
+        phase: 'submitted', storedSessionID: 'stored-3',
+        metadata: expect.objectContaining({
+          baselineCaptured: true, baselineMessageID: 'assistant-before', baselineSequence: 8,
+          baselineTotal: 9, baselineAssistantCount: 4, baselineRowSequence: 8,
+          promptDigest: 'user-a:可能已收到',
+        }),
+      }),
+    ])
+  })
+
+  it('does not publish live terminal content when two local users own the same stored session', async () => {
+    const coordinator = new FakeCoordinator()
+    const makeObserver = async (user: string, runtime: string, prompt: string) => {
+      const observer = new ChatPushRelayObserver(
+        coordinator,
+        { localUserID: user, source: 'gateway' },
+        () => 10_000,
+        { resolveTerminal: async () => ({ confirmed: false, correlated: false }) },
+      )
+      await observer.observeClientFrame(JSON.stringify({
+        id: `resume-${user}`, method: 'session.resume', params: { profile: 'default', session_id: 'shared' },
+      }))
+      observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+        id: `resume-${user}`, result: { session_id: runtime, stored_session_id: 'shared' },
+      })), false)
+      await observer.observeClientFrame(JSON.stringify({
+        id: `prompt-${user}`, method: 'prompt.submit', params: { session_id: runtime, text: prompt },
+      }))
+      observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
+        id: `prompt-${user}`, result: { status: 'accepted' },
+      })), false)
+      return observer
+    }
+    const first = await makeObserver('user-a', 'runtime-a', '任务 A')
+    const second = await makeObserver('user-b', 'runtime-b', '任务 B')
+    await first.flush()
+    await second.flush()
+    second.observeUpstreamFrame(Buffer.from(JSON.stringify({
+      method: 'event', params: {
+        type: 'message.complete', session_id: 'runtime-b', payload: { text: '不应泄漏的正文' },
+      },
+    })), false)
+    await second.flush()
+    expect(coordinator.notifications).toHaveLength(0)
+  })
+
+  it('filters group events by terminal agent state, subscription, and stable event id', async () => {
+    const coordinator = new FakeCoordinator()
+    coordinator.anchor = { epoch, cursor: 5 }
+    coordinator.subscribers.set('room-1', ['user-a', 'user-a', 'user-b'])
+    const processor = new GroupPushEventProcessor({ epoch, cursor: 5 }, coordinator)
+
+    await expect(processor.accept({ type: 'group.ready', epoch, cursor: 5 })).resolves.toBe('ready')
+    await processor.accept({
+      type: 'group.event', epoch, cursor: 6, roomId: 'room-1', event: 'message.upsert',
+      payload: { id: 'message-1', roomId: 'room-1', senderKind: 'agent', status: 'streaming', content: '半成品' },
+    })
+    expect(coordinator.notifications).toHaveLength(0)
+    await processor.accept({
+      type: 'group.event', epoch, cursor: 7, roomId: 'room-1', event: 'message.upsert',
+      payload: {
+        id: 'message-1', roomId: 'room-1', topicId: 'topic-1', senderKind: 'agent', senderName: '夭夭',
+        status: 'completed', content: '团队最终回复',
+      },
+    })
+    expect(coordinator.notifications).toHaveLength(2)
+    expect(coordinator.notifications.every(item => item.kind === 'group.message.completed')).toBe(true)
+    expect((coordinator.notifications[0] as GroupPushCandidate).roomID).toBe('room-1')
+
+    await processor.accept({
+      type: 'group.event', epoch, cursor: 8, roomId: 'room-1', event: 'message.upsert',
+      payload: { id: 'message-1', roomId: 'room-1', senderKind: 'agent', status: 'completed', content: '重复终态' },
+    })
+    expect(coordinator.notifications).toHaveLength(2)
+    await processor.accept({
+      type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'interaction.requested',
+      payload: {
+        id: 'interaction-1', roomId: 'room-1', kind: 'clarification', status: 'pending',
+        payload: { question: '选择哪个?' },
+      },
+    })
+    expect(coordinator.notifications.filter(item => item.kind === 'group.interaction.requested')).toHaveLength(2)
+    expect(coordinator.anchor).toEqual({ epoch, cursor: 9 })
+  })
+
+  it('does not advance the group cursor when durable enqueue fails', async () => {
+    const coordinator = new FakeCoordinator()
+    coordinator.anchor = { epoch, cursor: 1 }
+    coordinator.subscribers.set('room-1', ['user-a'])
+    coordinator.failEnqueue = true
+    const processor = new GroupPushEventProcessor({ epoch, cursor: 1 }, coordinator)
+    await expect(processor.accept({
+      type: 'group.event', epoch, cursor: 2, roomId: 'room-1', event: 'message.upsert',
+      payload: { id: 'message-1', roomId: 'room-1', senderKind: 'agent', status: 'failed', error: '失败' },
+    })).rejects.toThrow('outbox unavailable')
+    expect(coordinator.anchor).toEqual({ epoch, cursor: 1 })
+  })
+
+  it('resolves the authoritative latest assistant body from Hermes REST', async () => {
+    let requested = ''
+    const upstreamSession = {
+      request: async (path: string, options?: { search?: URLSearchParams }) => {
+        requested = `${path}?${options?.search?.toString()}`
+        return {
+          status: 200,
+          body: Buffer.from(JSON.stringify({
+            session: { title: '会话标题' },
+            messages: [
+              { id: 'assistant-1', run_id: 'run-1', role: 'assistant', content: '较早回复', sequence: 1, created_at: 1 },
+              { id: 'user-1', role: 'user', content: '问题', sequence: 2, created_at: 2 },
+              { id: 'assistant-2', run_id: 'run-2', role: 'assistant', content: '最终权威回复', sequence: 3, created_at: 3 },
+            ],
+          })),
+        }
+      },
+    } as unknown as UpstreamServiceSession
+    const resolver = new HermesChatNotificationResolver(upstreamSession)
+    const result = await resolver.resolveTerminal({
+      id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
+      storedSessionID: 'stored/a', requestID: 'prompt-1', queued: false, phase: 'accepted',
+      submittedAt: 1, expiresAt: 2,
+      metadata: {
+        baselineCaptured: true, baselineMessageID: 'assistant-1', baselineSequence: 1,
+        baselineAssistantCount: 1, baselineTotal: 2, runID: 'run-2',
+      },
+    }, 'run.completed', {})
+    expect(result).toEqual({
+      title: '会话标题', body: '最终权威回复', messageID: 'assistant-2', timestamp: 3_000,
+      confirmed: true, correlated: true,
+    })
+    expect(requested).toContain('/api/sessions/stored%2Fa/messages?')
+    expect(requested).toContain('order=latest')
+    expect(requested).toContain('include_compacted=true')
+    expect(requested).toContain('profile=default')
+  })
+
+  it('loads the title from the real Hermes session detail endpoint', async () => {
+    const paths: string[] = []
+    const upstream = {
+      request: async (path: string) => {
+        paths.push(path)
+        return path.endsWith('/messages')
+          ? {
+              status: 200,
+              body: Buffer.from(JSON.stringify({
+                session_id: 'stored-real',
+                messages: [{ id: 2, role: 'assistant', content: '真实正文', run_id: 'run-real' }],
+                pagination: { total: 2 },
+              })),
+            }
+          : { status: 200, body: Buffer.from('{"id":"stored-real","title":"真实会话标题"}') }
+      },
+    } as unknown as UpstreamServiceSession
+    const resolved = await new HermesChatNotificationResolver(upstream).resolveTerminal({
+      id: 'real-title', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
+      storedSessionID: 'stored-real', requestID: 'prompt', queued: false, phase: 'accepted',
+      submittedAt: 1, expiresAt: 60_000,
+      metadata: {
+        baselineCaptured: true, baselineSequence: 0, baselineMessageID: 'old',
+        baselineAssistantCount: 0, baselineTotal: 1, runID: 'run-real',
+      },
+    }, 'run.completed', {})
+    expect(resolved).toMatchObject({ title: '真实会话标题', body: '真实正文', confirmed: true })
+    expect(paths).toEqual([
+      '/api/sessions/stored-real/messages',
+      '/api/sessions/stored-real',
+    ])
+  })
+
+  it('does not confirm an old assistant when only a newer user message exists', async () => {
+    const upstreamSession = {
+      request: async () => ({
+        status: 200,
+        body: Buffer.from(JSON.stringify({
+          pagination: { total: 2 },
+          messages: [
+            { id: 'assistant-old', run_id: 'run-old', role: 'assistant', content: '旧回复', sequence: 1, timestamp: 1 },
+            { id: 'user-new', role: 'user', content: '新问题', sequence: 2, timestamp: 2 },
+          ],
+        })),
+      }),
+    } as unknown as UpstreamServiceSession
+    const resolver = new HermesChatNotificationResolver(upstreamSession)
+    const resolved = await resolver.resolveTerminal({
+      id: 'job-old-only', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
+      storedSessionID: 'stored-1', requestID: 'prompt-1', queued: false, phase: 'submitted',
+      submittedAt: 2_000, expiresAt: 60_000,
+      metadata: {
+        baselineCaptured: true, baselineMessageID: 'assistant-old', baselineSequence: 1,
+        baselineTotal: 1, baselineAssistantCount: 1, runID: 'run-new',
+      },
+    }, 'run.completed', {})
+    expect(resolved.confirmed).toBe(false)
+    expect(resolved.body).toBeUndefined()
+  })
+
+  it('does not attribute another user run in the same stored session', async () => {
+    const upstreamSession = {
+      request: async () => ({
+        status: 200,
+        body: Buffer.from(JSON.stringify({
+          messages: [
+            { id: 'assistant-old', run_id: 'run-old', role: 'assistant', content: '旧回复', sequence: 1 },
+            { id: 'assistant-a', run_id: 'run-user-a', role: 'assistant', content: '用户 A 的回复', sequence: 2 },
+            { id: 'assistant-b', run_id: 'run-user-b', role: 'assistant', content: '用户 B 的回复', sequence: 3 },
+          ],
+        })),
+      }),
+    } as unknown as UpstreamServiceSession
+    const resolver = new HermesChatNotificationResolver(upstreamSession)
+    const common = {
+      profile: 'default', runtimeSessionID: 'runtime', storedSessionID: 'shared-session',
+      requestID: 'prompt', queued: false, phase: 'accepted' as const, submittedAt: 1, expiresAt: 60_000,
+      metadata: {
+        baselineCaptured: true, baselineMessageID: 'assistant-old', baselineSequence: 1,
+        baselineAssistantCount: 1, baselineTotal: 1,
+      },
+    }
+    const userA = await resolver.resolveTerminal({
+      ...common, id: 'job-a', localUserID: 'user-a', metadata: { ...common.metadata, runID: 'run-user-a' },
+    }, 'run.completed', {})
+    const userB = await resolver.resolveTerminal({
+      ...common, id: 'job-b', localUserID: 'user-b', metadata: { ...common.metadata, runID: 'run-user-b' },
+    }, 'run.completed', {})
+    const missing = await resolver.resolveTerminal({
+      ...common, id: 'job-c', localUserID: 'user-c', metadata: { ...common.metadata, runID: 'run-user-c' },
+    }, 'run.completed', {})
+    expect(userA).toMatchObject({ confirmed: true, body: '用户 A 的回复' })
+    expect(userB).toMatchObject({ confirmed: true, body: '用户 B 的回复' })
+    expect(missing.confirmed).toBe(false)
+  })
+
+  it('recognizes an authoritative failed assistant record during recovery', async () => {
+    const upstreamSession = {
+      request: async () => ({
+        status: 200,
+        body: Buffer.from(JSON.stringify({
+          messages: [{
+            id: 'assistant-failed', run_id: 'run-failed', role: 'assistant', status: 'failed',
+            error: 'permission denied', sequence: 2,
+          }],
+        })),
+      }),
+    } as unknown as UpstreamServiceSession
+    const resolved = await new HermesChatNotificationResolver(upstreamSession).resolveTerminal({
+      id: 'job-failed', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
+      storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'accepted',
+      submittedAt: 1, expiresAt: 60_000,
+      metadata: {
+        baselineCaptured: true, baselineAssistantCount: 0, baselineTotal: 0,
+        baselineRowSequence: 0, runID: 'run-failed',
+      },
+    }, 'run.completed', {})
+    expect(resolved).toMatchObject({
+      confirmed: true, failed: true, error: 'permission denied', body: 'permission denied',
+    })
+  })
+
+  it('correlates the real Hermes history shape by a private prompt digest without run IDs', async () => {
+    const digest = async (userID: string, prompt: string) => `${userID}:${prompt}`
+    let repeated = false
+    const upstreamSession = {
+      request: async () => ({
+        status: 200,
+        body: Buffer.from(JSON.stringify({
+          messages: [
+            { id: 10, role: 'assistant', content: '旧回复' },
+            { id: 11, role: 'user', content: '生成报告' },
+            ...(repeated ? [{ id: 12, role: 'user', content: '生成报告' }] : []),
+            { id: 13, role: 'assistant', content: '新报告' },
+          ],
+        })),
+      }),
+    } as unknown as UpstreamServiceSession
+    const resolver = new HermesChatNotificationResolver(upstreamSession, digest)
+    const job: ChatPushJob = {
+      id: 'job-real-shape', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
+      storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'submitted',
+      submittedAt: 1, expiresAt: 60_000,
+      metadata: {
+        baselineCaptured: true, baselineRowSequence: 10,
+        promptDigest: await digest('user-a', '生成报告'),
+      },
+    }
+    await expect(resolver.resolveTerminal(job, 'run.completed', {})).resolves.toMatchObject({
+      confirmed: true, correlated: true, body: '新报告', messageID: '13',
+    })
+    repeated = true
+    await expect(resolver.resolveTerminal(job, 'run.completed', {})).resolves.toMatchObject({
+      confirmed: false, correlated: false,
+    })
+  })
+
+  it('uses row zero as the recovery baseline for a new empty session', async () => {
+    const upstream = {
+      request: async () => ({ status: 200, body: Buffer.from('{"messages":[],"pagination":{"total":0}}') }),
+    } as unknown as UpstreamServiceSession
+    const baseline = await new HermesChatNotificationResolver(upstream).captureBaseline({
+      id: 'empty', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
+      storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'submitted',
+      submittedAt: 1, expiresAt: 60_000,
+    })
+    expect(baseline).toMatchObject({ total: 0, assistantCount: 0, lastRowSequence: 0 })
+  })
+
+  it('starts pending chat recovery immediately and enforces the socket cap', async () => {
+    const coordinator = new FakeCoordinator()
+    const now = Date.now()
+    for (let index = 1; index <= 3; index += 1) {
+      coordinator.saveChatJob({
+        id: `job-${index}`, localUserID: 'user-a', profile: 'default',
+        runtimeSessionID: `runtime-${index}`, storedSessionID: `stored-${index}`,
+        requestID: `prompt-${index}`, queued: false, phase: index === 1 ? 'submitted' : 'accepted',
+        metadata: {
+          baselineCaptured: true, baselineTotal: 0, baselineAssistantCount: 0,
+          runID: `run-${index}`, disconnectedAt: now,
+        },
+        submittedAt: now + index, expiresAt: now + 60_000,
+      })
+    }
+    coordinator.saveChatJob({
+      id: 'job-online', localUserID: 'user-a', profile: 'default',
+      runtimeSessionID: 'runtime-online', storedSessionID: 'stored-online',
+      requestID: 'prompt-online', queued: false, phase: 'accepted',
+      submittedAt: now + 4, expiresAt: now + 60_000,
+      metadata: { baselineCaptured: true, baselineRowSequence: 0, promptDigest: 'digest-online' },
+    })
+    coordinator.saveChatJob({
+      id: 'job-submitted', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-submitted',
+      storedSessionID: 'stored-submitted', requestID: 'prompt-submitted', queued: false, phase: 'submitted',
+      submittedAt: now + 4, expiresAt: now + 60_000,
+    })
+    coordinator.saveChatJob({
+      id: 'job-expired', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-expired',
+      storedSessionID: 'stored-expired', requestID: 'prompt-expired', queued: false, phase: 'submitted',
+      submittedAt: now - 60_000, expiresAt: now - 1,
+    })
+    let ticketRequests = 0
+    const session = {
+      request: () => {
+        ticketRequests += 1
+        return new Promise<never>(() => undefined)
+      },
+    } as unknown as UpstreamServiceSession
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
+      imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const manager = new ChatPushJobManager(config, session, coordinator, {
+      maximumConcurrent: 2, reconcileMilliseconds: 60_000,
+    })
+    manager.start()
+    await manager.reconcile()
+    expect(ticketRequests).toBe(2)
+    expect(coordinator.jobs.has('job-submitted')).toBe(true)
+    expect(coordinator.jobs.has('job-expired')).toBe(false)
+
+    coordinator.completeChatJob('job-1')
+    await manager.reconcile()
+    expect(ticketRequests).toBe(3)
+    manager.stop()
+  })
+
+  it('does not push an uncorrelated terminal frame seen by a recovery watcher', async () => {
+    const upstream = createServer((request, response) => {
+      response.setHeader('Content-Type', 'application/json')
+      const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
+      if (path === '/api/status') response.end('{"auth_required":false}')
+      else if (path === '/api/auth/ws-ticket') response.end('{"ticket":"ownership-ticket"}')
+      else response.end('{}')
+    })
+    const sockets = new WebSocketServer({ noServer: true })
+    upstream.on('upgrade', (_request, socket, head) => {
+      sockets.handleUpgrade(_request, socket, head, client => {
+        client.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: {} } }))
+        client.on('message', raw => {
+          const frame = JSON.parse(raw.toString()) as Record<string, unknown>
+          client.send(JSON.stringify({ id: frame.id, result: { session_id: 'runtime', running: true } }))
+          setTimeout(() => client.send(JSON.stringify({
+            method: 'event', params: {
+              type: 'message.complete', session_id: 'runtime', payload: { text: '另一位用户的回复' },
+            },
+          })), 10)
+        })
+      })
+    })
+    const port = await listen(upstream)
+    closers.push(async () => {
+      for (const client of sockets.clients) client.terminate()
+      sockets.close()
+      await closeServer(upstream)
+    })
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL(`http://127.0.0.1:${port}`),
+      allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
+      imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const session = new UpstreamServiceSession(new UpstreamClient(config.upstream), () => undefined)
+    const coordinator = new FakeCoordinator()
+    const job: ChatPushJob = {
+      id: 'job-owned', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'old-runtime',
+      storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'accepted',
+      submittedAt: Date.now(), expiresAt: Date.now() + 60_000,
+      metadata: { baselineCaptured: true, baselineRowSequence: 1, promptDigest: 'user-a:我的任务' },
+    }
+    coordinator.saveChatJob(job)
+    const watcher = new HermesChatPushJobWatcher(
+      job,
+      config,
+      session,
+      coordinator,
+      { resolveTerminal: async () => ({ confirmed: false, correlated: false }) },
+    )
+    watcher.start()
+    await new Promise(resolve => setTimeout(resolve, 100))
+    watcher.stop()
+    expect(coordinator.notifications).toHaveLength(0)
+    expect(coordinator.jobs.has(job.id)).toBe(true)
+  })
+
+  it('recovers a persisted chat job through ticket, resume, and authoritative REST', async () => {
+    const rpcFrames: Array<Record<string, unknown>> = []
+    const submittedAt = Date.now()
+    let messageReads = 0
+    const upstream = createServer((request, response) => {
+      response.setHeader('Content-Type', 'application/json')
+      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
+      if (url.pathname === '/api/status') response.end(JSON.stringify({ auth_required: false }))
+      else if (url.pathname === '/api/auth/ws-ticket') response.end(JSON.stringify({ ticket: 'push-ticket' }))
+      else if (url.pathname === '/api/sessions/stored-1/messages') {
+        messageReads += 1
+        response.end(JSON.stringify({
+          session: { title: '恢复后的会话' },
+          messages: [{
+            id: messageReads === 1 ? 'old-assistant' : 'new-assistant',
+            run_id: messageReads === 1 ? 'run-old' : 'run-recovery',
+            role: 'assistant',
+            content: messageReads === 1 ? '提交任务前的旧回复' : '后台恢复取得的最终正文',
+            sequence: messageReads === 1 ? 2 : 3,
+            created_at: (submittedAt + (messageReads === 1 ? -10_000 : 1_000)) / 1_000,
+          }],
+        }))
+      } else {
+        response.statusCode = 404
+        response.end('{}')
+      }
+    })
+    const sockets = new WebSocketServer({ noServer: true })
+    upstream.on('upgrade', (request, socket, head) => {
+      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
+      if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'push-ticket') return socket.destroy()
+      sockets.handleUpgrade(request, socket, head, client => {
+        client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } }))
+        client.on('message', raw => {
+          const frame = JSON.parse(raw.toString()) as Record<string, unknown>
+          rpcFrames.push(frame)
+          client.send(JSON.stringify({
+            jsonrpc: '2.0', id: frame.id,
+            result: { session_id: 'runtime-recovered', stored_session_id: 'stored-1', run_id: 'run-recovery', running: false },
+          }))
+        })
+      })
+    })
+    const port = await listen(upstream)
+    closers.push(async () => {
+      for (const client of sockets.clients) client.terminate()
+      sockets.close()
+      await closeServer(upstream)
+    })
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL(`http://127.0.0.1:${port}`),
+      allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
+      imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const session = new UpstreamServiceSession(new UpstreamClient(config.upstream), () => undefined)
+    const coordinator = new FakeCoordinator()
+    const job: ChatPushJob = {
+      id: 'job-recovery', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-old',
+      storedSessionID: 'stored-1', requestID: 'prompt-1', queued: false, phase: 'accepted',
+      metadata: {
+        baselineCaptured: true, baselineTotal: 2, baselineAssistantCount: 1,
+        baselineMessageID: 'old-assistant', baselineSequence: 2, runID: 'run-recovery',
+      },
+      submittedAt, expiresAt: submittedAt + 60_000,
+    }
+    coordinator.saveChatJob(job)
+    const watcher = new HermesChatPushJobWatcher(job, config, session, coordinator)
+    watcher.start()
+    await new Promise(resolve => setTimeout(resolve, 150))
+    expect(coordinator.notifications).toHaveLength(0)
+    expect(coordinator.jobs.has(job.id)).toBe(true)
+    await new Promise<void>((resolve, reject) => {
+      const timeout = setTimeout(() => reject(new Error('notification timeout')), 3_000)
+      const poll = setInterval(() => {
+        if (!coordinator.notifications.length) return
+        clearTimeout(timeout)
+        clearInterval(poll)
+        resolve()
+      }, 10)
+    })
+    watcher.stop()
+
+    expect(rpcFrames.length).toBeGreaterThanOrEqual(2)
+    expect(rpcFrames.every(frame => frame.method === 'session.resume')).toBe(true)
+    expect(rpcFrames[0]).toMatchObject({
+      params: { session_id: 'stored-1', profile: 'default', close_on_disconnect: false, omit_messages: true },
+    })
+    expect(coordinator.notifications[0]).toMatchObject({
+      kind: 'chat.completed', title: '恢复后的会话', body: '后台恢复取得的最终正文', sessionID: 'stored-1',
+    })
+    expect(messageReads).toBeGreaterThanOrEqual(2)
+    expect(coordinator.jobs.size).toBe(0)
+  })
+})

+ 193 - 0
tests/server/pushRoutes.test.ts

@@ -0,0 +1,193 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import { PushCoordinator } from '../../src/server/pushCoordinator.js'
+import { createAuthenticatedApplication } from './authenticatedApplication.js'
+
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+describe('iOS push routes', () => {
+  it('registers an installation, manages team subscriptions, badge, and admin status', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-routes-'))
+    roots.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+      apns: { keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes' },
+    }
+    const push = new PushCoordinator({
+      home,
+      apns: config.apns,
+      provider: { send: async () => ({ disposition: 'success', status: 200 }) },
+      autoFlush: false,
+    })
+    const fetchImpl = (async (input: string | URL | Request) => {
+      const path = new URL(input instanceof Request ? input.url : String(input)).pathname
+      if (path === '/api/status') return Response.json({ auth_required: false })
+      if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
+      if (path.endsWith('/messages')) return Response.json({ items: [{ id: 'baseline', seq: 12 }] })
+      return Response.json({ ok: true })
+    }) as typeof fetch
+    const runtime = createAuthenticatedApplication({ config, push, fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const host = (value: request.Test) => value.set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+    const installationID = '11111111-1111-4111-8111-111111111111'
+    const accountID = '22222222-2222-4222-8222-222222222222'
+    const roomID = '33333333-3333-4333-8333-333333333333'
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    const capabilities = await host(agent.get('/api/push/v1/capabilities')).expect(200)
+    expect(capabilities.body).toMatchObject({
+      protocolVersion: 1,
+      enabled: true,
+      topic: 'cn.samien.yaoyao.hermes',
+      maximumSummaryCharacters: 180,
+    })
+    await agent.get('/api/app/push/v1/capabilities').set('Host', '127.0.0.1:8800').expect(200)
+
+    const registered = await host(agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
+      .send({ token: 'ab'.repeat(32), environment: 'development', appVersion: '1.2 (139)' })
+      .expect(200)
+    expect(registered.body.installation).toMatchObject({
+      installationId: installationID,
+      clientAccountId: accountID,
+      environment: 'development',
+    })
+
+    await host(agent.put(`/api/app/push/v1/group-subscriptions/${roomID}`))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ enabled: true })
+      .expect(200, /"enabled":true/)
+    const subscriptions = await host(agent.get('/api/app/push/v1/group-subscriptions')).expect(200)
+    expect(subscriptions.body.roomIds).toEqual([roomID])
+
+    await host(agent.post(`/api/push/v1/installations/${installationID}/accounts/${accountID}/badge-reset`))
+      .send({})
+      .expect(200, { badge: 0 })
+    const status = await host(agent.get('/api/app/system/push-status')).expect(200)
+    expect(status.body).toMatchObject({ configured: true, healthy: true, registrationCount: 1, topic: 'cn.samien.yaoyao.hermes' })
+
+    await host(agent.delete(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
+      .expect(200, /"removed":true/)
+    expect(push.status().registrationCount).toBe(0)
+  })
+
+  it('rejects malformed registration and subscription input', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-validation-'))
+    roots.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const runtime = createAuthenticatedApplication({ config })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const headers = (value: request.Test) => value
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+
+    await headers(agent.put('/api/push/v1/installations/11111111-1111-4111-8111-111111111111/accounts/22222222-2222-4222-8222-222222222222'))
+      .send({ token: 'not-a-token', environment: 'unknown' })
+      .expect(400, /environment must be development or production/)
+    await headers(agent.put('/api/push/v1/group-subscriptions/33333333-3333-4333-8333-333333333333'))
+      .send({ enabled: 'yes' })
+      .expect(400, /enabled must be a boolean/)
+  })
+
+  it('automatically subscribes the current user after Web or native team participation', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-auto-subscribe-'))
+    roots.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const fetchImpl = (async (input: string | URL | Request) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      if (url.pathname === '/api/status') return Response.json({ auth_required: false })
+      if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (url.pathname === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
+      if (/\/api\/plugins\/yaoyao\/v1\/rooms\/[0-9a-f-]{36}\/messages$/.test(url.pathname)) {
+        return Response.json({ message: { id: 'message', seq: 7, senderKind: 'human', content: 'hello' }, runs: [] })
+      }
+      return Response.json({ ok: true })
+    }) as typeof fetch
+    const runtime = createAuthenticatedApplication({ config, fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const firstRoom = '44444444-4444-4444-8444-444444444444'
+    const secondRoom = '55555555-5555-4555-8555-555555555555'
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    await agent.post(`/api/app/groups/rooms/${firstRoom}/messages`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ requestId: 'request-web', clientMessageId: 'client-web', content: 'Web 参与', mentionAgentIds: [] })
+      .expect(200)
+    await agent.post(`/api/plugins/yaoyao/v1/rooms/${secondRoom}/messages`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .send({ requestId: 'request-ios', clientMessageId: 'client-ios', content: 'iOS 参与', mentionAgentIds: [] })
+      .expect(200)
+
+    const subscriptions = await agent.get('/api/push/v1/group-subscriptions')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    expect(subscriptions.body.roomIds).toEqual([firstRoom, secondRoom])
+    expect(subscriptions.body.subscriptions.every((item: { lastMessageSeq?: number }) => item.lastMessageSeq === 7)).toBe(true)
+  })
+
+  it('never turns a successful team send into a failure when optional push state is unavailable', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-state-failure-'))
+    roots.push(home)
+    mkdirSync(join(home, 'push'))
+    writeFileSync(join(home, 'push', 'state.json'), '{broken', { mode: 0o600 })
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: true,
+    }
+    const fetchImpl = (async (input: string | URL | Request) => {
+      const path = new URL(input instanceof Request ? input.url : String(input)).pathname
+      if (path === '/api/status') return Response.json({ auth_required: false })
+      if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
+      if (path.endsWith('/messages')) {
+        return Response.json({ message: { id: 'accepted', seq: 1, senderKind: 'human', content: '已接收' }, runs: [] })
+      }
+      return Response.json({ ok: true })
+    }) as typeof fetch
+    const push = new PushCoordinator({ home, autoFlush: false })
+    const runtime = createAuthenticatedApplication({ config, fetchImpl, push })
+    runtimes.push(runtime)
+    // The error event is operational telemetry; suppress Koa's default stderr
+    // output while asserting the primary response remains authoritative.
+    runtime.app.on('error', () => undefined)
+    const agent = request.agent(runtime.app.callback())
+    const roomID = '66666666-6666-4666-8666-666666666666'
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    await agent.post(`/api/app/groups/rooms/${roomID}/messages`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ requestId: 'request', clientMessageId: 'client', content: '仍应成功', mentionAgentIds: [] })
+      .expect(200, /accepted/)
+  })
+})

+ 17 - 0
tests/serviceCli.test.ts

@@ -35,6 +35,23 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
     expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n      <string>1</string>')
   })
 
+  it('preserves APNs provider settings in the LaunchAgent', () => {
+    const plist = launchAgentPlist({
+      environment: {
+        HERMES_YAOYAO_APNS_KEY_FILE: '/Users/test/AuthKey_ABC123.p8',
+        HERMES_YAOYAO_APNS_KEY_ID: 'ABC123',
+        HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123',
+        HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes',
+      },
+    })
+
+    expect(plist).toContain('<key>HERMES_YAOYAO_APNS_KEY_FILE</key>')
+    expect(plist).toContain('/Users/test/AuthKey_ABC123.p8')
+    expect(plist).toContain('<key>HERMES_YAOYAO_APNS_KEY_ID</key>')
+    expect(plist).toContain('<key>HERMES_YAOYAO_APNS_TEAM_ID</key>')
+    expect(plist).toContain('<key>HERMES_YAOYAO_APNS_TOPIC</key>')
+  })
+
   it('can point the managed service at the stable current release link', () => {
     const root = join(homedir(), '.local', 'share', 'hermes-yaoyao', 'current')
     const plist = launchAgentPlist({ serviceRoot: root })

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov