Эх сурвалжийг харах

发布 v0.3.27:统一跨端会话所有权与实时同步

LSamien 1 сар өмнө
parent
commit
f44c109367

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.26**,配套 iOS **1.3(183)**。详见 [发布说明](docs/releases/v0.3.26.md)。
+当前发布版本:**v0.3.27**,配套 iOS **1.3(184)**。详见 [发布说明](docs/releases/v0.3.27.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 9 - 0
docs/releases/v0.3.27.md

@@ -0,0 +1,9 @@
+# v0.3.27
+
+- 普通聊天归属改由 Web 服务持久化的“用户 + Agent + canonical Session ID”登记决定;`source` 只保留来源元数据,不再参与列表、续聊或修改权限。
+- Web 和 iOS 统一使用 `/api/app/sessions` 权威投影;跨端新建、标题生成和 `sessions.changed` 会实时刷新另一端,无需重载页面。
+- 修复 iOS 未识别 Hermes 实际 `session.title` 事件、新建失败后留下本地幽灵会话、未读辅助路径错误,以及重启后误入上次会话。
+- 服务端自行补齐 `order=recent` 与 `archived=exclude`,避免旧调用方因缺参将上游错误暴露为 HTTP 500。
+- 旧 v0.3.26 registry 仅在本地用户和缓存 owner 都唯一且一致时做一次性接管;多用户、配对设备、搜索、分页、置顶和标题竞态都按新的 owner registry 隔离。
+
+配套 iOS 1.3(184)。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.26",
+  "version": "0.3.27",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.26",
+      "version": "0.3.27",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.26",
+  "version": "0.3.27",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.26",
-  "webVersion": "0.3.26",
-  "gitTag": "v0.3.26"
+  "releaseVersion": "0.3.27",
+  "webVersion": "0.3.27",
+  "gitTag": "v0.3.27"
 }

+ 47 - 2
src/client/stores/chat.ts

@@ -22,6 +22,7 @@ import { useAuthStore } from './auth'
 interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number }
 const historyCache = new ScopedCache<CachedHistory>('chat-history-v1')
 const INFLIGHT_SESSION_STORAGE_PREFIX = 'hermes-yaoyao:inflight-chat-sessions:'
+const SESSION_LIST_REFRESH_DEBOUNCE_MS = 200
 
 interface InflightSessionMarker {
   profile: string
@@ -104,6 +105,8 @@ export const useChatStore = defineStore('chat', () => {
   let reconnectTimer: number | undefined
   let reconnectAttempt = 0
   let sessionLoadGeneration = 0
+  let sessionListProfile: string | undefined
+  let sessionListRefreshTimer: number | undefined
   let nextSessionCursor: string | undefined
   let modelLoadGeneration = 0
   let unreadLoadGeneration = 0
@@ -166,6 +169,30 @@ export const useChatStore = defineStore('chat', () => {
     if (key === activeKey && !desiredModelRoutes.has(key)) syncSelectedModel(model, provider)
   }
 
+  function reconcileSessionTitle(profile: string | undefined, sessionId: string, title: string): void {
+    const normalized = title.trim()
+    if (!sessionId || !normalized) return
+    const index = sessions.value.findIndex(session => session.id === sessionId
+      && (!profile || !session.profile || session.profile === profile))
+    if (index < 0 || sessions.value[index].title === normalized) return
+    sessions.value.splice(index, 1, { ...sessions.value[index], title: normalized })
+  }
+
+  function cancelSessionListRefresh(): void {
+    if (sessionListRefreshTimer === undefined) return
+    window.clearTimeout(sessionListRefreshTimer)
+    sessionListRefreshTimer = undefined
+  }
+
+  function scheduleSessionListRefresh(): void {
+    if (auth.status !== 'authenticated') return
+    cancelSessionListRefresh()
+    sessionListRefreshTimer = window.setTimeout(() => {
+      sessionListRefreshTimer = undefined
+      void loadSessions(sessionListProfile ?? auth.activeProfile?.name, sessionView).catch(() => undefined)
+    }, SESSION_LIST_REFRESH_DEBOUNCE_MS)
+  }
+
   function cacheScope(profile: string): string {
     return `${auth.user?.id ?? 'anonymous'}:${profile}`
   }
@@ -266,8 +293,8 @@ export const useChatStore = defineStore('chat', () => {
     const draftIndex = sessions.value.findIndex(session => session.id === oldState.route.sessionId && session.profile === oldState.route.profile)
     const now = Date.now() / 1000
     const summary: SessionSummary = draftIndex >= 0
-      ? { ...sessions.value[draftIndex], id: storedSessionId, updatedAt: now }
-      : { id: storedSessionId, profile: oldState.route.profile, source: 'web', title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now }
+      ? { ...sessions.value[draftIndex], id: storedSessionId, owned: true, updatedAt: now }
+      : { id: storedSessionId, profile: oldState.route.profile, source: 'web', owned: true, title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now }
     if (draftIndex >= 0) sessions.value.splice(draftIndex, 1, summary)
     else sessions.value.unshift(summary)
     return routes[newKey]
@@ -276,10 +303,24 @@ export const useChatStore = defineStore('chat', () => {
   socket.onEvent(event => {
     if (event.type === 'gateway.ready') return
     const payload = record(event.payload)
+    if (event.type === 'sessions.changed') {
+      scheduleSessionListRefresh()
+      return
+    }
     const runtimeId = string(event.session_id ?? payload.session_id ?? payload.sessionId)
     let key = runtimeRoutes.get(runtimeId)
     if (!key) key = Object.keys(routes).find(candidate => routes[candidate].runtimeSessionId === runtimeId
       || routes[candidate].route.sessionId === runtimeId)
+    if (event.type === 'session.title' || event.type === 'session.title.updated') {
+      const state = key ? routes[key] : undefined
+      const storedSessionId = state?.route.sessionId
+        || string(payload.stored_session_id ?? payload.storedSessionId ?? payload.session_key
+          ?? payload.session_id ?? payload.sessionId)
+      const profile = state?.route.profile || string(event.profile ?? payload.profile) || undefined
+      reconcileSessionTitle(profile, storedSessionId, string(payload.title))
+      scheduleSessionListRefresh()
+      if (!key) return
+    }
     if (!key) return
     let state = routes[key]
     if (event.type === 'session.info') {
@@ -335,6 +376,7 @@ export const useChatStore = defineStore('chat', () => {
     runtimePromises.clear()
     desiredModelRoutes.clear()
     pendingModelConfirmations.clear()
+    cancelSessionListRefresh()
     for (const state of Object.values(routes)) {
       state.runtimeSessionId = undefined
       state.serverFastMode = undefined
@@ -361,6 +403,8 @@ export const useChatStore = defineStore('chat', () => {
     isLoadingMoreSessions.value = false
     activeSessionId.value = undefined
     activeProfileName.value = undefined
+    sessionListProfile = undefined
+    cancelSessionListRefresh()
     runtimeRoutes.clear()
     for (const key of Object.keys(routes)) delete routes[key]
     unreadCounts.value = {}
@@ -378,6 +422,7 @@ export const useChatStore = defineStore('chat', () => {
   async function loadSessions(profile?: string, view: 'chat' | 'history' = sessionView): Promise<void> {
     sessionView = view
     const requestedProfile = profile || undefined
+    sessionListProfile = requestedProfile
     const loadGeneration = ++sessionLoadGeneration
     isLoading.value = true
     error.value = undefined

+ 2 - 0
src/client/utils/normalize.ts

@@ -113,6 +113,7 @@ export function normalizeModel(value: unknown): ModelOption {
 export function normalizeSession(value: unknown, fallbackProfile?: string): SessionSummary {
   const source = record(value)
   const modelConfig = jsonRecord(pick(source, 'model_config', 'modelConfig'))
+  const owned = pick(source, 'owned', 'is_owned', 'isOwned')
   const startedAt = number(pick(source, 'started_at', 'startedAt', 'created_at', 'createdAt'))
   const updatedAt = number(pick(source, 'last_active', 'lastActive', 'updated_at', 'updatedAt'), startedAt)
   const id = string(pick(source, 'id', 'session_id', 'sessionId', 'stored_session_id', 'storedSessionId'))
@@ -120,6 +121,7 @@ export function normalizeSession(value: unknown, fallbackProfile?: string): Sess
     id,
     profile: string(source.profile, fallbackProfile) || undefined,
     source: string(source.source, 'cli'),
+    owned: owned === undefined ? undefined : bool(owned),
     title: string(source.title, '未命名会话'),
     preview: string(source.preview) || undefined,
     model: string(source.model ?? modelConfig.model) || undefined,

+ 10 - 0
src/client/utils/sessionOwnership.ts

@@ -0,0 +1,10 @@
+import type { SessionSummary } from '@shared/types'
+
+/**
+ * The 15300 registry owns chat membership. A local Web draft is the only
+ * pre-registration row allowed on the chat surface.
+ */
+export function isOwnedChatSession(session: Pick<SessionSummary, 'id' | 'owned'>): boolean {
+  return session.owned === true
+    || (session.owned === undefined && session.id.startsWith('draft-'))
+}

+ 6 - 3
src/client/views/ChatView.vue

@@ -25,6 +25,7 @@ import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPr
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
 import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
 import { estimateConversationTokens } from '@/utils/contextUsage'
+import { isOwnedChatSession } from '@/utils/sessionOwnership'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { getSession } from '@/api/sessions'
@@ -58,7 +59,7 @@ function handleModelCatalogChanged(event: Event) {
 }
 
 const sessions = computed(() => [...chat.sessions]
-  .filter(session => session.source === 'web'))
+  .filter(isOwnedChatSession))
 const agentNames = computed(() => new Map(auth.profiles.map(profile => [
   profile.name,
   profile.agentName || profile.displayName || profile.name,
@@ -146,7 +147,7 @@ async function refreshSessions() {
   await chat.loadSessions(profile, 'chat')
   if (requestedId) {
     const target = await getSession(requestedId, profile).catch(() => undefined)
-    if (target && target.source !== 'web') {
+    if (target && !isOwnedChatSession(target)) {
       await router.replace({ path: `/history/${encodeURIComponent(requestedId)}`, query: { profile } })
       return
     }
@@ -154,7 +155,9 @@ async function refreshSessions() {
   if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
   if (requestedId && profile && routeProfile() !== profile) await router.replace(sessionLocation(requestedId, profile))
   void chat.loadUnread(profile).catch(() => undefined)
-  if (!requestedId && chat.activeSession?.source !== 'web') chat.clearSelection()
+  if (!requestedId && chat.activeSession && !isOwnedChatSession(chat.activeSession)) {
+    chat.clearSelection()
+  }
 }
 
 async function chooseSession(id: string) {

+ 7 - 3
src/client/views/HistoryView.vue

@@ -25,6 +25,7 @@ import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPr
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
 import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
 import { estimateConversationTokens } from '@/utils/contextUsage'
+import { isOwnedChatSession } from '@/utils/sessionOwnership'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { getSession } from '@/api/sessions'
@@ -58,7 +59,8 @@ function handleModelCatalogChanged(event: Event) {
 }
 
 const sessions = computed(() => [...chat.sessions]
-  .filter(session => !['cron', 'ios_group', 'yaoyao_workspace', 'web'].includes(session.source)))
+  .filter(session => !isOwnedChatSession(session)
+    && !['cron', 'ios_group', 'yaoyao_workspace'].includes(session.source)))
 const agentNames = computed(() => new Map(auth.profiles.map(profile => [
   profile.name,
   profile.agentName || profile.displayName || profile.name,
@@ -146,7 +148,7 @@ async function refreshSessions() {
   await chat.loadSessions(profile, 'history')
   if (requestedId) {
     const target = await getSession(requestedId, profile).catch(() => undefined)
-    if (target?.source === 'web') {
+    if (target && isOwnedChatSession(target)) {
       await router.replace({ path: `/chat/${encodeURIComponent(requestedId)}`, query: { profile } })
       return
     }
@@ -154,7 +156,9 @@ async function refreshSessions() {
   if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
   if (requestedId && profile && routeProfile() !== profile) await router.replace(sessionLocation(requestedId, profile))
   void chat.loadUnread(profile).catch(() => undefined)
-  if (!requestedId && chat.activeSession?.source === 'web') chat.clearSelection()
+  if (!requestedId && chat.activeSession && isOwnedChatSession(chat.activeSession)) {
+    chat.clearSelection()
+  }
 }
 
 async function chooseSession(id: string) {

+ 10 - 10
src/server/app.ts

@@ -138,16 +138,15 @@ export function createApplication(options: ApplicationOptions = {}): Application
     () => auth.upstreamCredentials(configuredUpstreamCredentials),
   )
   let chatCache = options.chatCache
-  if (!chatCache && config.chatCacheMode !== 'upstream-only') {
-    try {
-      chatCache = new ChatCacheCoordinator(
-        new ChatCacheStore(config.home),
-        upstreamSession,
-        config.chatCacheMode ?? 'prefer-local',
-      )
-    } catch {
-      chatCache = undefined
-    }
+  if (!chatCache) {
+    chatCache = new ChatCacheCoordinator(
+      new ChatCacheStore(
+        config.home,
+        auth.soleUserIDForLegacyMigration(),
+      ),
+      upstreamSession,
+      config.chatCacheMode ?? 'prefer-local',
+    )
   }
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
   const profileIdentities = options.profileIdentities
@@ -218,6 +217,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)
+    if (owner.startsWith('device:')) return
     if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
     const data = frame.payload ?? {}, text = JSON.stringify(data)
     const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))

+ 407 - 71
src/server/chatCache.ts

@@ -53,6 +53,16 @@ function sessionSource(value: Record<string, unknown>): string {
   return String(value.source ?? '').trim()
 }
 
+function sessionTitle(value: Record<string, unknown>): string {
+  return String(value.title ?? '').trim()
+}
+
+function isPlaceholderTitle(value: string): boolean {
+  return new Set([
+    '', '新对话', '新会话', '未命名对话', '未命名会话', 'new conversation', 'new session', 'untitled',
+  ]).has(value.trim().toLowerCase())
+}
+
 function messageID(value: Record<string, unknown>, fallback: string): string {
   const id = String(value.id ?? value.row_id ?? value.message_id ?? value.messageId ?? '').trim()
   return id || createHash('sha256').update(JSON.stringify(value) + fallback).digest('hex')
@@ -80,7 +90,7 @@ export class ChatCacheStore {
   readonly db: DatabaseSync
   readonly assetsRoot: string
 
-  constructor(home: string) {
+  constructor(home: string, legacySingleUserID?: string) {
     mkdirSync(home, { recursive: true, mode: 0o700 })
     const path = join(home, 'chat-cache.sqlite3')
     this.db = new DatabaseSync(path)
@@ -90,7 +100,9 @@ export class ChatCacheStore {
     this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
       CREATE TABLE IF NOT EXISTS chat_sessions(
         owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
-        runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
+        runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL, authoritative_title TEXT,
+        authoritative_title_at INTEGER NOT NULL DEFAULT 0,
+        owned_at INTEGER, ownership_evidence TEXT,
         sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
         last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
         PRIMARY KEY(owner,profile,session_id));
@@ -117,7 +129,81 @@ export class ChatCacheStore {
         owner TEXT NOT NULL, cache_key TEXT NOT NULL, kind TEXT NOT NULL,
         profile TEXT NOT NULL, session_id TEXT, status INTEGER NOT NULL,
         headers TEXT NOT NULL, body TEXT NOT NULL, sync_state TEXT NOT NULL,
-        saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));`)
+        saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));
+      CREATE TABLE IF NOT EXISTS chat_meta(
+        key TEXT PRIMARY KEY, value TEXT NOT NULL);`)
+    const sessionColumns = this.db.prepare('PRAGMA table_info(chat_sessions)').all() as Array<{ name: string }>
+    this.db.exec('BEGIN IMMEDIATE')
+    try {
+      if (!sessionColumns.some(column => column.name === 'authoritative_title')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN authoritative_title TEXT')
+      }
+      if (!sessionColumns.some(column => column.name === 'authoritative_title_at')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN authoritative_title_at INTEGER NOT NULL DEFAULT 0')
+      }
+      if (!sessionColumns.some(column => column.name === 'owned_at')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN owned_at INTEGER')
+      }
+      if (!sessionColumns.some(column => column.name === 'ownership_evidence')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN ownership_evidence TEXT')
+      }
+      this.db.exec(`CREATE INDEX IF NOT EXISTS chat_sessions_owned
+        ON chat_sessions(owner,profile,owned_at,last_synced_at)`)
+      const ownershipMigration = this.db.prepare(
+        "SELECT value FROM chat_meta WHERE key='ownership-registry-v1'",
+      ).get() as { value?: string } | undefined
+      if (!ownershipMigration) {
+        // A single cached owner is trusted only when the persisted local user
+        // database independently confirms the same sole identity.
+        const legacyOwners = this.db.prepare(
+          'SELECT DISTINCT owner FROM chat_sessions ORDER BY owner',
+        ).all() as Array<{ owner: string }>
+        if (legacyOwners.length === 1 && legacyOwners[0]!.owner === legacySingleUserID) {
+          this.db.exec(`UPDATE chat_sessions SET
+            owned_at=COALESCE(owned_at,last_synced_at),
+            ownership_evidence=COALESCE(ownership_evidence,'legacy-single-user')`)
+        }
+      }
+      // Recover interrupted/new-schema writes only from explicit command proof.
+      this.db.exec(`UPDATE chat_sessions SET
+        owned_at=COALESCE(owned_at,(
+          SELECT MIN(chat_events.received_at) FROM chat_events
+          WHERE chat_events.owner=chat_sessions.owner
+            AND chat_events.profile=chat_sessions.profile
+            AND chat_events.session_id=chat_sessions.session_id
+            AND chat_events.event_type IN ('command:session.create','command:session.branch')
+        )),
+        ownership_evidence=COALESCE(ownership_evidence,'command-migration')
+        WHERE EXISTS (
+          SELECT 1 FROM chat_events
+          WHERE chat_events.owner=chat_sessions.owner
+            AND chat_events.profile=chat_sessions.profile
+            AND chat_events.session_id=chat_sessions.session_id
+            AND chat_events.event_type IN ('command:session.create','command:session.branch')
+        )`)
+      const legacyTitles = this.db.prepare(`SELECT owner,profile,session_id,data FROM chat_sessions
+        WHERE authoritative_title IS NULL`).all() as Array<{
+          owner: string; profile: string; session_id: string; data: string
+        }>
+      const backfillTitle = this.db.prepare(`UPDATE chat_sessions SET authoritative_title=?
+        WHERE owner=? AND profile=? AND session_id=? AND authoritative_title IS NULL`)
+      for (const row of legacyTitles) {
+        let summary: Record<string, unknown> = {}
+        try { summary = object(JSON.parse(row.data)) ?? {} } catch { continue }
+        const title = sessionTitle(summary)
+        if (!isPlaceholderTitle(title)) backfillTitle.run(title, row.owner, row.profile, row.session_id)
+      }
+      // List bodies written before ownership became an explicit projection do
+      // not carry `owned` and may have been filtered by `source`. Rebuild them on
+      // first use after every restart instead of serving a semantically old page.
+      this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE kind='list'").run()
+      this.db.prepare(`INSERT INTO chat_meta(key,value) VALUES('ownership-registry-v1','complete')
+        ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run()
+      this.db.exec('COMMIT')
+    } catch (error) {
+      this.db.exec('ROLLBACK')
+      throw error
+    }
   }
 
   close(): void { this.db.close() }
@@ -126,7 +212,8 @@ export class ChatCacheStore {
     const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
     const bytes = Number((this.db.prepare(`SELECT COALESCE(SUM(size),0) bytes FROM chat_attachments WHERE state='cached'`).get() as { bytes: number }).bytes)
     return {
-      sessions: count('chat_sessions'),
+      sessions: Number((this.db.prepare(`SELECT COUNT(*) count FROM chat_sessions
+        WHERE owned_at IS NOT NULL`).get() as { count: number }).count),
       messages: count('chat_messages'),
       events: count('chat_events'),
       attachments: count('chat_attachments'),
@@ -141,16 +228,16 @@ export class ChatCacheStore {
   }
 
   putSnapshot(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
-    response: UpstreamResponse, knownWeb = false): boolean {
+    response: UpstreamResponse, ownedList = false, requestStartedAt = Date.now()): boolean {
     const payload = parseResponse(response)
     if (!payload) return false
-    if (kind === 'list' && !knownWeb) return false
-    const isWeb = knownWeb || this.payloadIsWeb(owner, profile, sessionID, payload)
-    if (!isWeb) return false
+    if (kind === 'list' && !ownedList) return false
+    const isOwnedChat = ownedList || this.payloadBelongsToOwnedChat(owner, profile, sessionID, payload)
+    if (!isOwnedChat) return false
     const now = Date.now()
     this.db.exec('BEGIN IMMEDIATE')
     try {
-      this.ingestPayload(owner, profile, sessionID, payload, now)
+      this.ingestPayload(owner, profile, sessionID, payload, now, kind, requestStartedAt)
       this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
         VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,cache_key) DO UPDATE SET status=excluded.status,headers=excluded.headers,
         body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
@@ -166,7 +253,7 @@ export class ChatCacheStore {
 
   messagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
     const session = this.db.prepare(`SELECT data,sync_state,complete FROM chat_sessions
-      WHERE owner=? AND profile=? AND session_id=? AND source='web'`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
+      WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
     if (!session || !bool(session.complete)) return undefined
     const totalRow = this.db.prepare('SELECT COUNT(*) count FROM chat_messages WHERE owner=? AND profile=? AND session_id=?')
       .get(owner, profile, sessionID) as { count: number }
@@ -211,6 +298,10 @@ export class ChatCacheStore {
       || (rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
     this.db.prepare(`UPDATE chat_sessions SET sync_state=?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
       WHERE owner=? AND profile=? AND session_id=?`).run(state, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
+    if (type === 'session.title' || type === 'session.title.updated') {
+      const title = String(object(frame.payload)?.title ?? frame.title ?? '').trim()
+      if (title) this.updateOwnedSessionTitle(owner, profile, sessionID, title, now)
+    }
     this.db.prepare(`UPDATE chat_snapshots SET sync_state=? WHERE owner=? AND (session_id=? OR kind='list')`)
       .run(state, owner, sessionID)
     this.db.prepare(`INSERT INTO chat_sync_cursors(owner,profile,event_cursor,sync_state,last_synced_at)
@@ -221,20 +312,29 @@ export class ChatCacheStore {
 
   recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string): void {
     const now = Date.now()
-    const summary = { id: sessionID, profile, source: 'web', title: '新对话', started_at: now, last_active: now }
-    this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
-      VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET runtime_id=excluded.runtime_id,
-      source='web',last_synced_at=excluded.last_synced_at`)
-      .run(owner, profile, sessionID, runtimeID, 'web', JSON.stringify(summary), 'stale', 0, now)
+    const summary = { id: sessionID, profile, source: 'unknown', title: '新对话', started_at: now, last_active: now }
+    this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET runtime_id=excluded.runtime_id,
+      last_synced_at=excluded.last_synced_at,owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
+      ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
+      .run(owner, profile, sessionID, runtimeID, 'unknown', JSON.stringify(summary), 'stale', 0, now, now, 'route-confirmed')
+    this.markListsStale(owner)
   }
 
   recordCommand(owner: string, profile: string, sessionID: string, method: string,
     params: Record<string, unknown>): void {
     const now = Date.now()
-    const summary = { id: sessionID, profile, source: 'web', title: '新对话', started_at: now, last_active: now }
-    this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at)
-      VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET sync_state='stale',complete=0`)
-      .run(owner, profile, sessionID, 'web', JSON.stringify(summary), 'stale', 0, now)
+    const source = sessionSource(params) || 'unknown'
+    const establishesOwnership = method === 'session.create' || method === 'session.branch'
+    const summary = { id: sessionID, profile, source, title: '新对话', started_at: now, last_active: now }
+    this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
+      VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET
+      source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
+      sync_state='stale',complete=0,last_synced_at=excluded.last_synced_at,
+      owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
+      ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
+      .run(owner, profile, sessionID, source, JSON.stringify(summary), 'stale', 0, now,
+        establishesOwnership ? now : null, establishesOwnership ? `command:${method}` : null)
     const payload = JSON.stringify(params)
     const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
     this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
@@ -247,6 +347,14 @@ export class ChatCacheStore {
     this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND kind='list'").run(owner)
   }
 
+  recordAuthoritativeTitle(owner: string, profile: string, sessionID: string, title: string): void {
+    const normalized = title.trim()
+    if (!normalized) return
+    this.updateOwnedSessionTitle(owner, profile, sessionID, normalized, Date.now())
+    this.db.prepare(`UPDATE chat_snapshots SET sync_state='stale'
+      WHERE owner=? AND (session_id=? OR kind='list')`).run(owner, sessionID)
+  }
+
   deleteSession(owner: string, profile: string, sessionID: string): void {
     const assets = this.db.prepare(`SELECT DISTINCT local_path FROM chat_attachments
       WHERE owner=? AND profile=? AND session_id=? AND local_path IS NOT NULL`)
@@ -276,21 +384,31 @@ export class ChatCacheStore {
     return Boolean(this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE owner=? AND source_path=? LIMIT 1').get(owner, sourcePath))
   }
 
-  isWebSession(owner: string, profile: string, sessionID: string): boolean {
-    return Boolean(this.db.prepare("SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND source='web'")
-      .get(owner, profile, sessionID))
-  }
-
   ownsSession(owner: string, profile: string, sessionID: string): boolean {
     return Boolean(this.db.prepare(
-      'SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?',
+      'SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL',
     ).get(owner, profile, sessionID))
   }
 
+  ownedSessionMetadata(owner: string, profile: string, sessionID: string): {
+    owned: boolean
+    authoritativeTitle?: string
+  } {
+    const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
+      .get(owner, profile, sessionID) as { authoritative_title?: string | null } | undefined
+    const authoritativeTitle = String(row?.authoritative_title ?? '').trim()
+    return {
+      owned: Boolean(row),
+      ...(authoritativeTitle ? { authoritativeTitle } : {}),
+    }
+  }
+
   mergeOwnedSessionsIntoList(
     owner: string,
     fallbackProfile: string,
     response: UpstreamResponse,
+    page: { offset?: number; limit?: number; archived?: string } = {},
   ): UpstreamResponse {
     const payload = parseResponse(response)
     if (!payload) return response
@@ -300,61 +418,199 @@ export class ChatCacheStore {
     if (!collectionKey) return response
 
     const rows = (fallbackProfile
-      ? this.db.prepare(`SELECT profile,session_id,data FROM chat_sessions
-          WHERE owner=? AND profile=? ORDER BY last_synced_at DESC LIMIT 1000`)
+      ? this.db.prepare(`SELECT profile,session_id,source,data,authoritative_title,last_synced_at FROM chat_sessions
+          WHERE owner=? AND profile=? AND owned_at IS NOT NULL`)
           .all(owner, fallbackProfile)
-      : this.db.prepare(`SELECT profile,session_id,data FROM chat_sessions
-          WHERE owner=? ORDER BY last_synced_at DESC LIMIT 1000`).all(owner)) as Array<{
+      : this.db.prepare(`SELECT profile,session_id,source,data,authoritative_title,last_synced_at FROM chat_sessions
+          WHERE owner=? AND owned_at IS NOT NULL`).all(owner)) as Array<{
             profile: string
             session_id: string
+            source: string
             data: string
+            authoritative_title: string | null
+            last_synced_at: number
           }>
     const owned = new Map(rows.map(row => [
       `${row.profile}\u0000${row.session_id}`,
       row,
     ]))
     const merged = new Map<string, Record<string, unknown>>()
-    for (const value of payload[collectionKey] as unknown[]) {
-      const session = object(value)
-      if (!session) continue
-      const id = sessionIDOf(session)
-      const profile = profileOf(session, fallbackProfile) || 'default'
-      if (!id) continue
-      const key = `${profile}\u0000${id}`
-      if (sessionSource(session) === 'web' || owned.has(key)) {
-        merged.set(key, { ...session, source: 'web' })
-      }
-    }
     for (const [key, row] of owned) {
-      if (merged.has(key)) continue
       let stored: Record<string, unknown> = {}
       try { stored = object(JSON.parse(row.data)) ?? {} } catch { /* Ignore damaged display metadata. */ }
+      const storedSource = sessionSource(stored)
       merged.set(key, {
         ...stored,
         id: row.session_id,
         profile: row.profile,
-        source: 'web',
+        source: storedSource && storedSource !== 'unknown' ? storedSource : row.source,
+        ...(row.authoritative_title?.trim() ? { title: row.authoritative_title.trim() } : {}),
+        _yaoyao_registry_activity: row.last_synced_at,
+        owned: true,
       })
     }
+    for (const value of payload[collectionKey] as unknown[]) {
+      const session = object(value)
+      if (!session) continue
+      const id = sessionIDOf(session)
+      const profile = profileOf(session, fallbackProfile) || 'default'
+      if (!id) continue
+      const key = `${profile}\u0000${id}`
+      const row = owned.get(key)
+      if (row) {
+        const title = row.authoritative_title?.trim()
+        merged.set(key, {
+          ...merged.get(key),
+          ...session,
+          ...(title ? { title } : {}),
+          _yaoyao_registry_activity: row.last_synced_at,
+          owned: true,
+        })
+      }
+    }
     const activity = (session: Record<string, unknown>): number => Number(
       session.last_active_at
         ?? session.last_active
         ?? session.updated_at
         ?? session.started_at
+        ?? session._yaoyao_registry_activity
         ?? 0,
     ) || 0
-    const sessions = [...merged.values()].sort((left, right) =>
-      activity(right) - activity(left))
+    const archived = (session: Record<string, unknown>): boolean => bool(
+      session.is_archived ?? session.isArchived ?? session.archived,
+    )
+    const pinned = (session: Record<string, unknown>): boolean => bool(
+      session.is_pinned ?? session.isPinned ?? session.pinned,
+    )
+    const archivedMode = page.archived ?? 'exclude'
+    const allSessions = [...merged.values()]
+      .filter(session => archivedMode === 'include'
+        || (archivedMode === 'only' ? archived(session) : !archived(session)))
+      .sort((left, right) => Number(pinned(right)) - Number(pinned(left))
+        || activity(right) - activity(left))
+    const offset = Math.max(0, Math.trunc(page.offset ?? 0))
+    const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
+    const sessions = allSessions.slice(offset, offset + limit).map((session) => {
+      const publicSession = { ...session }
+      delete publicSession._yaoyao_registry_activity
+      return publicSession
+    })
     const body = Buffer.from(JSON.stringify({
       ...payload,
       [collectionKey]: sessions,
-      total: Math.max(Number(payload.total) || 0, sessions.length),
+      total: allSessions.length,
+      offset,
+      limit,
     }))
     const headers = new Headers(response.headers)
     headers.delete('content-length')
     return { ...response, headers, body }
   }
 
+  excludeOwnedSessionsFromList(
+    owner: string,
+    fallbackProfile: string,
+    response: UpstreamResponse,
+    page: { offset?: number; limit?: number } = {},
+  ): UpstreamResponse {
+    const payload = parseResponse(response)
+    if (!payload) return response
+    const collectionKey = Array.isArray(payload.items)
+      ? 'items'
+      : Array.isArray(payload.sessions) ? 'sessions' : undefined
+    if (!collectionKey) return response
+    const allSessions = (payload[collectionKey] as unknown[]).filter((value) => {
+      const session = object(value)
+      if (!session) return true
+      const id = sessionIDOf(session)
+      const profile = profileOf(session, fallbackProfile) || 'default'
+      return !id || !this.ownsSession(owner, profile, id)
+    })
+    const offset = Math.max(0, Math.trunc(page.offset ?? 0))
+    const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
+    const sessions = allSessions.slice(offset, offset + limit)
+    const headers = new Headers(response.headers)
+    headers.delete('content-length')
+    return {
+      ...response,
+      headers,
+      body: Buffer.from(JSON.stringify({
+        ...payload,
+        [collectionKey]: sessions,
+        total: allSessions.length,
+        offset,
+        limit,
+      })),
+    }
+  }
+
+  markSessionOwnership(
+    owner: string,
+    fallbackProfile: string,
+    sessionID: string,
+    response: UpstreamResponse,
+  ): UpstreamResponse {
+    const payload = parseResponse(response)
+    if (!payload) return response
+    const session = object(payload.session)
+    const candidate = session ?? payload
+    const id = sessionIDOf(candidate) || sessionID
+    const profile = profileOf(candidate, fallbackProfile) || fallbackProfile || 'default'
+    const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`).get(owner, profile, id) as { authoritative_title?: string | null } | undefined
+    const owned = Boolean(row)
+    const title = String(row?.authoritative_title ?? '').trim()
+    const body = session
+      ? Buffer.from(JSON.stringify({
+          ...payload,
+          session: { ...session, ...(title ? { title } : {}), owned },
+          owned,
+        }))
+      : Buffer.from(JSON.stringify({ ...payload, ...(title ? { title } : {}), owned }))
+    const headers = new Headers(response.headers)
+    headers.delete('content-length')
+    return { ...response, headers, body }
+  }
+
+  markOwnedSearchResults(
+    owner: string,
+    fallbackProfile: string,
+    response: UpstreamResponse,
+    view?: 'chat' | 'history',
+    limit = 100,
+  ): UpstreamResponse {
+    const payload = parseResponse(response)
+    if (!payload || !Array.isArray(payload.results)) return response
+    const marked = payload.results.map((value) => {
+      const session = object(value)
+      if (!session) return value
+      const id = sessionIDOf(session)
+      const profile = profileOf(session, fallbackProfile) || fallbackProfile || 'default'
+      const metadata = id
+        ? this.ownedSessionMetadata(owner, profile, id)
+        : { owned: false }
+      return {
+        ...session,
+        ...(metadata.authoritativeTitle ? { title: metadata.authoritativeTitle } : {}),
+        owned: metadata.owned,
+      }
+    })
+    const projected = marked.filter((value) => {
+      if (!view) return true
+      const session = object(value)
+      if (!session) return false
+      return view === 'chat' ? session.owned === true : session.owned !== true
+    })
+    const results = projected.slice(0, Math.max(1, Math.min(100, Math.trunc(limit))))
+    const headers = new Headers(response.headers)
+    headers.delete('content-length')
+    return {
+      ...response,
+      headers,
+      body: Buffer.from(JSON.stringify({ ...payload, results, total: projected.length })),
+    }
+  }
+
   storeAttachment(owner: string, sourcePath: string, response: UpstreamResponse): string | undefined {
     if (response.status !== 200 || !response.body.length) return undefined
     const sha256 = createHash('sha256').update(response.body).digest('hex')
@@ -376,33 +632,72 @@ export class ChatCacheStore {
     return { stream: createReadStream(path), size: statSync(path).size }
   }
 
-  private payloadIsWeb(owner: string, profile: string, sessionID: string | undefined, payload: Record<string, unknown>): boolean {
+  private payloadBelongsToOwnedChat(owner: string, profile: string, sessionID: string | undefined,
+    payload: Record<string, unknown>): boolean {
     const session = object(payload.session) ?? (sessionID ? payload : undefined)
-    if (session && sessionSource(session) === 'web') return true
+    if (session) {
+      const id = sessionIDOf(session) || sessionID || ''
+      const sessionProfile = profileOf(session, profile) || profile || 'default'
+      if (id && this.ownsSession(owner, sessionProfile, id)) return true
+    }
     const sessions = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
-    if (sessions.some(value => object(value) && sessionSource(object(value)!) === 'web')) return true
-    if (!sessionID) return false
-    return Boolean(this.db.prepare("SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND source='web'")
-      .get(owner, profile, sessionID))
+    if (sessions.some(value => {
+      const candidate = object(value)
+      if (!candidate) return false
+      const id = sessionIDOf(candidate)
+      const sessionProfile = profileOf(candidate, profile) || profile || 'default'
+      return Boolean(id && this.ownsSession(owner, sessionProfile, id))
+    })) return true
+    return Boolean(sessionID && this.ownsSession(owner, profile || 'default', sessionID))
   }
 
   private ingestPayload(owner: string, fallbackProfile: string, sessionID: string | undefined,
-    payload: Record<string, unknown>, now: number): void {
+    payload: Record<string, unknown>, now: number, kind: string, requestStartedAt: number): void {
     const candidates: Record<string, unknown>[] = []
     const direct = object(payload.session)
     if (direct) candidates.push(direct)
     const listed = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
     for (const value of listed) if (object(value)) candidates.push(object(value)!)
-    if (sessionID && !direct && sessionSource(payload)) candidates.push(payload)
+    if (sessionID && !direct && (sessionIDOf(payload) || sessionSource(payload) || typeof payload.title === 'string')) {
+      candidates.push(payload)
+    }
     for (const session of candidates) {
-      if (sessionSource(session) !== 'web') continue
-      const id = sessionIDOf(session)
+      const id = sessionIDOf(session) || sessionID || ''
       if (!id) continue
       const profile = profileOf(session, fallbackProfile) || fallbackProfile || 'default'
+      const registered = this.ownsSession(owner, profile, id)
+      const source = sessionSource(session)
+      if (!registered) continue
+      const titleRow = this.db.prepare(`SELECT authoritative_title,authoritative_title_at FROM chat_sessions
+        WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, id) as {
+          authoritative_title?: string | null; authoritative_title_at?: number
+        } | undefined
+      const authoritativeTitle = String(titleRow?.authoritative_title ?? '').trim()
+      const authoritativeTitleAt = Number(titleRow?.authoritative_title_at ?? 0)
+      const incomingTitle = sessionTitle(session)
+      const preservesAuthoritativeTitle = Boolean(authoritativeTitle)
+        && (kind !== 'detail' || isPlaceholderTitle(incomingTitle)
+          || requestStartedAt <= authoritativeTitleAt)
+      const storedSession = preservesAuthoritativeTitle
+        ? { ...session, title: authoritativeTitle }
+        : session
       this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
-        VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,source='web',
+        VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,
+        source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
         sync_state='current',last_synced_at=excluded.last_synced_at`)
-        .run(owner, profile, id, null, 'web', JSON.stringify(session), 'current', 0, now)
+        .run(owner, profile, id, null, source || 'unknown', JSON.stringify(storedSession), 'current', 0, now)
+      const acceptedTitle = sessionTitle(storedSession)
+      if (acceptedTitle && !isPlaceholderTitle(acceptedTitle)
+        && (!authoritativeTitle || kind === 'detail')) {
+        this.db.prepare(`UPDATE chat_sessions SET authoritative_title=?,authoritative_title_at=?
+          WHERE owner=? AND profile=? AND session_id=?`).run(
+            acceptedTitle,
+            Math.max(requestStartedAt, authoritativeTitleAt),
+            owner,
+            profile,
+            id,
+          )
+      }
     }
     const messages = Array.isArray(payload.messages) ? payload.messages.flatMap(value => object(value) ? [object(value)!] : []) : []
     if (!messages.length || !sessionID) return
@@ -429,6 +724,26 @@ export class ChatCacheStore {
     this.db.prepare(`UPDATE chat_sessions SET complete=MAX(complete,?),sync_state='current',last_synced_at=?
       WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, now, owner, profile, sessionID)
   }
+
+  private updateOwnedSessionTitle(owner: string, profile: string, sessionID: string, title: string, now: number): void {
+    const row = this.db.prepare(`SELECT source,data FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
+    if (!row) return
+    let summary: Record<string, unknown> = {}
+    try { summary = object(JSON.parse(String(row.data))) ?? {} } catch { /* Rebuild damaged display metadata below. */ }
+    const storedSource = sessionSource(summary)
+    const source = storedSource && storedSource !== 'unknown'
+      ? storedSource
+      : String(row.source || 'unknown')
+    this.db.prepare(`UPDATE chat_sessions SET data=?,authoritative_title=?,authoritative_title_at=?,sync_state='stale',last_synced_at=?
+      WHERE owner=? AND profile=? AND session_id=?`).run(JSON.stringify({
+      ...summary,
+      id: sessionID,
+      profile,
+      source,
+      title,
+    }), title, now, now, owner, profile, sessionID)
+  }
 }
 
 export class ChatCacheCoordinator {
@@ -437,28 +752,46 @@ export class ChatCacheCoordinator {
     readonly mode: ChatCacheMode = 'prefer-local') {}
 
   async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
-    load: () => Promise<UpstreamResponse>, knownWeb = false): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
-    if (this.mode === 'upstream-only') return { response: await load(), source: 'upstream', state: 'current' }
+    load: () => Promise<UpstreamResponse>, ownedList = false,
+    listPage: { offset?: number; limit?: number; archived?: string } = {}): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
+    if (this.mode === 'upstream-only') {
+      const upstreamResponse = await load()
+      return {
+        response: kind === 'list' && ownedList
+          ? this.store.mergeOwnedSessionsIntoList(owner, profile, upstreamResponse, listPage)
+          : upstreamResponse,
+        source: 'upstream',
+        state: 'current',
+      }
+    }
     const local = this.store.snapshot(owner, key)
+    const projectedLocal = local && kind === 'list' && ownedList
+      ? {
+          ...local,
+          response: this.store.mergeOwnedSessionsIntoList(owner, profile, local.response, listPage),
+        }
+      : local
     if (this.mode === 'prefer-local' && local?.state === 'current') {
       return { response: local.response, source: 'local', state: local.state }
     }
     try {
+      const requestStartedAt = Date.now()
       const upstreamResponse = await load()
       if (this.mode === 'prefer-local' && local && upstreamResponse.status >= 500) {
-        return { response: local.response, source: 'local', state: 'stale' }
+        return { response: projectedLocal!.response, source: 'local', state: 'stale' }
       }
-      const response = kind === 'list' && knownWeb
+      const response = kind === 'list' && ownedList
         ? this.store.mergeOwnedSessionsIntoList(
             owner,
             profile,
             upstreamResponse,
+            listPage,
           )
         : upstreamResponse
-      this.store.putSnapshot(owner, key, kind, profile, sessionID, response, knownWeb)
+      this.store.putSnapshot(owner, key, kind, profile, sessionID, response, ownedList, requestStartedAt)
       return { response, source: 'upstream', state: 'current' }
     } catch (error) {
-      if (this.mode === 'prefer-local' && local) return { response: local.response, source: 'local', state: 'stale' }
+      if (this.mode === 'prefer-local' && local) return { response: projectedLocal!.response, source: 'local', state: 'stale' }
       throw error
     }
   }
@@ -471,7 +804,7 @@ export class ChatCacheCoordinator {
     }
     const result = await this.read(owner, key, 'messages', profile, sessionID, load)
     if (result.source === 'upstream'
-      && this.store.isWebSession(owner, profile, sessionID)
+      && this.store.ownsSession(owner, profile, sessionID)
       && !this.store.messagePage(owner, profile, sessionID, 0, 1)) {
       void this.reconcile(owner, profile, sessionID)
     }
@@ -479,22 +812,23 @@ export class ChatCacheCoordinator {
   }
 
   observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
-    if (this.mode === 'upstream-only') return
     this.store.recordEvent(owner, profile, sessionID, frame)
-    if (String(frame.type) === 'message.complete') void this.reconcile(owner, profile, sessionID)
+    if (this.mode !== 'upstream-only' && String(frame.type) === 'message.complete') {
+      void this.reconcile(owner, profile, sessionID)
+    }
   }
 
   observeGlobal(owner: string, type: string): void {
-    if (this.mode !== 'upstream-only' && type === 'sessions.changed') this.store.markListsStale(owner)
+    if (type === 'sessions.changed') this.store.markListsStale(owner)
   }
 
   command(owner: string, profile: string, sessionID: string, method: string,
     params: Record<string, unknown>): void {
-    if (this.mode !== 'upstream-only') this.store.recordCommand(owner, profile, sessionID, method, params)
+    this.store.recordCommand(owner, profile, sessionID, method, params)
   }
 
   route(owner: string, profile: string, sessionID: string, runtimeID: string): void {
-    if (this.mode !== 'upstream-only') this.store.recordRoute(owner, profile, sessionID, runtimeID)
+    this.store.recordRoute(owner, profile, sessionID, runtimeID)
   }
 
   async reconcile(owner: string, profile: string, sessionID: string): Promise<void> {
@@ -504,13 +838,15 @@ export class ChatCacheCoordinator {
     const task = (async () => {
       const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
       const detailSearch = new URLSearchParams({ profile })
+      const detailStartedAt = Date.now()
       const detail = await this.upstream.request(detailPath, { search: detailSearch })
-      this.store.putSnapshot(owner, `detail:${profile}:${sessionID}`, 'detail', profile, sessionID, detail)
+      this.store.putSnapshot(owner, `detail:${profile}:${sessionID}`, 'detail', profile, sessionID, detail, false, detailStartedAt)
       let offset = 0
       for (let page = 0; page < 200; page++) {
         const search = new URLSearchParams({ profile, offset: String(offset), limit: '500', order: 'latest', include_compacted: 'true' })
+        const messagesStartedAt = Date.now()
         const response = await this.upstream.request(`${detailPath}/messages`, { search })
-        if (!this.store.putSnapshot(owner, `messages:${profile}:${sessionID}:${offset}:500`, 'messages', profile, sessionID, response)) return
+        if (!this.store.putSnapshot(owner, `messages:${profile}:${sessionID}:${offset}:500`, 'messages', profile, sessionID, response, false, messagesStartedAt)) return
         const payload = parseResponse(response)
         const messages = Array.isArray(payload?.messages) ? payload.messages : []
         const pagination = object(payload?.pagination) ?? {}

+ 5 - 0
src/server/localAuth.ts

@@ -123,6 +123,11 @@ export class LocalAuthStore {
     return this.#users.length === 0
   }
 
+  /** Exact persisted user identity used only to validate a legacy registry migration. */
+  soleUserIDForLegacyMigration(): string | undefined {
+    return this.#users.length === 1 ? this.#users[0]!.id : undefined
+  }
+
   setupAdmin(
     ctx: Koa.Context,
     usernameValue: string,

+ 3 - 16
src/server/realtimeApi.ts

@@ -25,22 +25,8 @@ export class RealtimeAPI {
       if (change.kind === 'reset') session.invalidateAuthentication()
     })
   }
-  private async canResume(profile: string, sessionId: string, jar?: CookieJar, owner?: string): Promise<boolean> {
-    if (owner && this.ownsSession?.(owner, profile, sessionId)) return true
-    const search = new URLSearchParams({ profile })
-    const path = `/api/sessions/${encodeURIComponent(sessionId)}`
-    const response = jar
-      ? await this.upstream.request(path, jar, { search })
-      : await this.session.request(path, { search })
-    if (response.status < 200 || response.status >= 300) return false
-    try {
-      const payload = JSON.parse(response.body.toString()) as Record<string, unknown>
-      const session = payload.session && typeof payload.session === 'object'
-        ? payload.session as Record<string, unknown> : payload
-      return session.source === 'web'
-    } catch {
-      return false
-    }
+  private async canResume(profile: string, sessionId: string, _jar?: CookieJar, owner?: string): Promise<boolean> {
+    return owner ? this.ownsSession?.(owner, profile, sessionId) ?? false : false
   }
   private principal(ctx: Koa.Context, device?: string): RealtimePrincipal {
     let key: string
@@ -53,6 +39,7 @@ export class RealtimeAPI {
       const bearer = ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
       jar = new CookieJar(this.pairings.authorize(device, bearer))
       key = `device:${device}`
+      owner = key
       valid = () => this.pairings.hasDevice(device)
       authorize = (kind, method) => {
         this.pairings.authorize(device, bearer, kind === 'groups' ? 'groups.read'

+ 23 - 1
src/server/realtimeBroker.ts

@@ -236,7 +236,7 @@ export class RealtimeBroker {
         u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
         r.observers.set(c.principal.key, c.principal)
         if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime)
-        if (nativeOwner && method === 'session.create') {
+        if (nativeOwner && (method === 'session.create' || method === 'session.branch')) {
           this.onNativeCommand(nativeOwner, profile, stored, method, p)
         }
         for (const pending of [result.pending_approval, result.pending_clarify, result.inflight?.pending_approval, result.inflight?.pending_clarify]) {
@@ -385,8 +385,30 @@ export class RealtimeBroker {
       if (!r && !['profiles.changed', 'sessions.changed', 'models.changed', 'pet.changed'].includes(p.type)) continue
       this.emit(c, 'frame', f)
     }
+    if (r && (p.type === 'session.title' || p.type === 'session.title.updated')) {
+      const owners = new Set([...r.observers.values()].flatMap(principal => {
+        const owner = principal.valid() ? this.nativeOwner(principal) : undefined
+        return owner ? [owner] : []
+      }))
+      for (const owner of owners) this.publishOwnerSessionsChanged(owner)
+    }
+  }
+  private publishOwnerSessionsChanged(owner: string): void {
+    this.onNativeGlobalEvent(owner, 'sessions.changed')
+    const frame = {
+      jsonrpc: '2.0',
+      method: 'event',
+      params: { type: 'sessions.changed', payload: { reason: 'session.title' } },
+    }
+    for (const channel of this.channels.values()) {
+      if (channel.kind !== 'chat' || !channel.principal.valid()
+        || this.nativeOwner(channel.principal) !== owner) continue
+      this.emit(channel, 'frame', frame)
+    }
   }
   private nativeOwner(principal: RealtimePrincipal): string | undefined {
+    const device = /^device:([^:]+)$/.exec(principal.key)
+    if (device) return `device:${device[1]}`
     const match = /^(?:user|push):([^:]+)/.exec(principal.key)
     return match?.[1]
   }

+ 11 - 5
src/server/realtimeProtocol.ts

@@ -48,6 +48,14 @@ function safeString(value: unknown, label: string, required = false): string | u
   return normalized
 }
 
+function normalizedCreateSource(value: unknown, fallback = 'web'): 'web' | 'ios' {
+  const source = safeString(value, 'source') ?? fallback
+  if (source !== 'web' && source !== 'ios') {
+    throw new HttpError(400, 'source must be web or ios', 'invalid_source')
+  }
+  return source
+}
+
 function normalizedConfigParams(params: Record<string, unknown>): Record<string, unknown> {
   const sessionID = safeString(params.session_id, 'session_id', true)!
   const key = safeString(params.key, 'config key', true)
@@ -90,19 +98,19 @@ function normalizedSessionOpenParams(
     return {
       session_id: safeString(params.session_id, 'session_id', true)!,
       profile,
-      source: 'web',
       close_on_disconnect: false,
       omit_messages: params.omit_messages === true,
       cols: columns,
     }
   }
+  const source = normalizedCreateSource(params.source)
   const title = safeString(params.title, 'title')
   const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
   const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
   if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
   return {
     profile,
-    source: 'web',
+    source,
     close_on_disconnect: false,
     cols: columns,
     ...(title ? { title } : {}),
@@ -116,7 +124,6 @@ function normalizedPairedSessionOpenParams(
   params: Record<string, unknown>,
 ): Record<string, unknown> {
   const profile = safeString(params.profile, 'profile', true)!
-  const source = safeString(params.source, 'source') ?? 'mobile'
   const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
     ? Math.min(500, Math.max(20, params.cols))
     : 80
@@ -124,12 +131,12 @@ function normalizedPairedSessionOpenParams(
     return {
       session_id: safeString(params.session_id, 'session_id', true)!,
       profile,
-      source,
       close_on_disconnect: false,
       omit_messages: params.omit_messages === true,
       cols: columns,
     }
   }
+  const source = safeString(params.source, 'source') ?? 'mobile'
   const title = safeString(params.title, 'title')
   const cwd = typeof params.cwd === 'string' && params.cwd.length <= 4_096 ? params.cwd : ''
   const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
@@ -345,4 +352,3 @@ export function groupCursor(value: unknown): number {
   }
   return cursor
 }
-

+ 213 - 38
src/server/routes.ts

@@ -328,11 +328,35 @@ function nativeSessionsReadOnly(): never {
   )
 }
 
-function requireWritableWebSession(response: UpstreamResponse, locallyOwned = false): void {
-  const payload = requireSuccess(response)
-  const session = payload.session && typeof payload.session === 'object' && !Array.isArray(payload.session)
-    ? payload.session as JsonObject : payload
-  if (!locallyOwned && session.source !== 'web') nativeSessionsReadOnly()
+function requireWritableOwnedSession(response: UpstreamResponse, locallyOwned = false): void {
+  requireSuccess(response)
+  if (!locallyOwned) nativeSessionsReadOnly()
+}
+
+function writableSessionPatch(input: JsonObject): JsonObject {
+  const allowed = new Set(['title', 'archived', 'pinned'])
+  const unknown = Object.keys(input).filter(key => !allowed.has(key))
+  if (unknown.length) {
+    throw new HttpError(400, `Session field is not writable: ${unknown[0]}`, 'invalid_session_patch')
+  }
+  const patch: JsonObject = {}
+  if (Object.hasOwn(input, 'title')) {
+    if (typeof input.title !== 'string' || !input.title.trim() || input.title.length > 500) {
+      throw new HttpError(400, 'Session title is invalid', 'invalid_session_patch')
+    }
+    patch.title = input.title.trim()
+  }
+  for (const key of ['archived', 'pinned'] as const) {
+    if (!Object.hasOwn(input, key)) continue
+    if (typeof input[key] !== 'boolean') {
+      throw new HttpError(400, `Session ${key} must be a boolean`, 'invalid_session_patch')
+    }
+    patch[key] = input[key]
+  }
+  if (!Object.keys(patch).length) {
+    throw new HttpError(400, 'Session patch is empty', 'invalid_session_patch')
+  }
+  return patch
 }
 
 function pairedProxyPath(rawPath: string): string {
@@ -379,6 +403,64 @@ async function proxy(
   })
 }
 
+async function completeSessionList(
+  upstream: UpstreamClient,
+  path: string,
+  jar: CookieJar,
+  baseSearch: URLSearchParams,
+): Promise<UpstreamResponse> {
+  const rows = new Map<string, unknown>()
+  let firstResponse: UpstreamResponse | undefined
+  let firstPayload: JsonObject | undefined
+  let collectionKey: 'items' | 'sessions' | undefined
+  let offset = 0
+  const limit = 100
+  for (let page = 0; page < 1_000; page++) {
+    const search = new URLSearchParams(baseSearch)
+    search.set('offset', String(offset))
+    search.set('limit', String(limit))
+    const response = await upstream.request(path, jar, { search })
+    if (!firstResponse) firstResponse = response
+    if (response.status < 200 || response.status >= 300) return response
+    const payload = parseJson(response)
+    const key = Array.isArray(payload.items) ? 'items'
+      : Array.isArray(payload.sessions) ? 'sessions' : undefined
+    if (!key) return response
+    if (!firstPayload) { firstPayload = payload; collectionKey = key }
+    const values = payload[key] as unknown[]
+    values.forEach((value, index) => {
+      const item = value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : undefined
+      const id = item ? String(item.id ?? item.session_id ?? item.sessionId ?? '') : ''
+      const profile = item ? String(item.profile ?? item.profile_name ?? '') : ''
+      rows.set(id ? `${profile}\u0000${id}` : `${offset}\u0000${index}`, value)
+    })
+    const responseOffset = Math.max(0, Number(payload.offset ?? offset) || offset)
+    const responseLimit = Math.max(1, Number(payload.limit ?? limit) || limit)
+    const total = Math.max(0, Number(payload.total ?? 0) || 0)
+    const nextOffset = responseOffset + responseLimit
+    if (!values.length || (total > 0 && nextOffset >= total)
+      || (total === 0 && values.length < responseLimit)) break
+    if (nextOffset <= offset) throw new HttpError(502, 'Hermes session pagination did not advance', 'invalid_upstream_pagination')
+    offset = nextOffset
+    if (page === 999) throw new HttpError(502, 'Hermes session list is too large to project', 'upstream_pagination_limit')
+  }
+  const response = firstResponse!
+  const payload = firstPayload!
+  const headers = new Headers(response.headers)
+  headers.delete('content-length')
+  return {
+    ...response,
+    headers,
+    body: Buffer.from(JSON.stringify({
+      ...payload,
+      [collectionKey!]: [...rows.values()],
+      total: rows.size,
+      offset: 0,
+      limit: rows.size,
+    })),
+  }
+}
+
 async function proxyAdminFeature(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
@@ -646,7 +728,8 @@ async function cachedChatRead(
     sessionID?: string
     offset?: number
     limit?: number
-    knownWeb?: boolean
+    archived?: string
+    ownedList?: boolean
     load: (jar: CookieJar) => Promise<UpstreamResponse>
   },
 ): Promise<void> {
@@ -656,18 +739,37 @@ async function cachedChatRead(
     return options.load(dependencies.upstreamSession.jar)
   }
   if (!dependencies.chatCache) {
-    sendUpstreamResponse(ctx, await load(), dependencies.upstreamSession.jar)
-    return
+    throw new HttpError(503, 'Chat ownership registry is unavailable', 'chat_registry_unavailable')
   }
   if (ctx.get('x-yaoyao-cache').toLowerCase() === 'bypass') {
-    const response = await load()
+    const requestStartedAt = Date.now()
+    const upstreamResponse = await load()
+    const response = options.kind === 'list' && options.ownedList
+      ? dependencies.chatCache.store.mergeOwnedSessionsIntoList(
+          user.id,
+          options.profile,
+          upstreamResponse,
+          { offset: options.offset, limit: options.limit, archived: options.archived },
+        )
+      : upstreamResponse
     dependencies.chatCache.store.putSnapshot(
       user.id, options.key, options.kind, options.profile,
-      options.sessionID, response, options.knownWeb,
+      options.sessionID, response, options.ownedList, requestStartedAt,
     )
     ctx.set('X-Yaoyao-Data-Source', 'upstream')
     ctx.set('X-Yaoyao-Sync-State', 'current')
-    sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
+    sendUpstreamResponse(
+      ctx,
+      options.sessionID
+        ? dependencies.chatCache.store.markSessionOwnership(
+            user.id,
+            options.profile,
+            options.sessionID,
+            response,
+          )
+        : response,
+      dependencies.upstreamSession.jar,
+    )
     return
   }
   const result = options.kind === 'messages' && options.sessionID
@@ -677,11 +779,23 @@ async function cachedChatRead(
     )
     : await dependencies.chatCache.read(
       user.id, options.key, options.kind, options.profile,
-      options.sessionID, load, options.knownWeb,
+      options.sessionID, load, options.ownedList,
+      { offset: options.offset, limit: options.limit, archived: options.archived },
     )
   ctx.set('X-Yaoyao-Data-Source', result.source)
   ctx.set('X-Yaoyao-Sync-State', result.state)
-  sendUpstreamResponse(ctx, result.response, dependencies.upstreamSession.jar)
+  sendUpstreamResponse(
+    ctx,
+    options.sessionID
+      ? dependencies.chatCache.store.markSessionOwnership(
+          user.id,
+          options.profile,
+          options.sessionID,
+          result.response,
+        )
+      : result.response,
+    dependencies.upstreamSession.jar,
+  )
 }
 
 async function bootstrap(
@@ -844,16 +958,34 @@ async function requireGatewayAuthentication(
 }
 
 async function searchSessions(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
+  const owner = dependencies.auth.require(ctx).id
+  if (!dependencies.chatCache) {
+    throw new HttpError(503, 'Chat ownership registry is unavailable', 'chat_registry_unavailable')
+  }
+  const chatCache = dependencies.chatCache
   await withJar(ctx, async (jar) => {
+    const view = ctx.query.view === 'chat' || ctx.query.view === 'history'
+      ? ctx.query.view : undefined
     const query = searchFrom(ctx, ['q', 'limit', 'source', 'profile'])
     const text = query.get('q')?.trim()
     if (!text) throw new HttpError(400, 'q is required', 'missing_query')
     const limit = Math.max(1, Math.min(100, Number(query.get('limit') ?? '50') || 50))
-    query.set('limit', String(limit))
+    query.set('limit', '100')
+    if (view) query.delete('source')
     if (!query.get('source')) query.set('exclude_sources', 'cron,ios_group,yaoyao_workspace')
     if (query.get('profile')) {
       const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: query })
-      sendUpstreamResponse(ctx, response, jar)
+      sendUpstreamResponse(
+        ctx,
+        chatCache.store.markOwnedSearchResults(
+          owner,
+          query.get('profile') || 'default',
+          response,
+          view,
+          limit,
+        ),
+        jar,
+      )
       return
     }
 
@@ -881,7 +1013,21 @@ async function searchSessions(ctx: Koa.Context, dependencies: RouteDependencies)
       const rightRank = Number(right.rank ?? right.last_active ?? right.started_at ?? 0)
       return rightRank - leftRank
     })
-    json(ctx, 200, { results: results.slice(0, limit) })
+    const marked = results.map((result) => {
+      const profile = typeof result.profile === 'string' ? result.profile : 'default'
+      const id = String(result.id ?? result.session_id ?? result.sessionId ?? '')
+      const metadata = id
+        ? chatCache.store.ownedSessionMetadata(owner, profile, id)
+        : { owned: false }
+      return {
+        ...result,
+        ...(metadata.authoritativeTitle ? { title: metadata.authoritativeTitle } : {}),
+        owned: metadata.owned,
+      }
+    })
+    const projected = marked.filter(result => !view
+      || (view === 'chat' ? result.owned : !result.owned))
+    json(ctx, 200, { results: projected.slice(0, limit), total: projected.length })
   })
 }
 
@@ -1881,18 +2027,49 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.get('/api/app/sessions', async (ctx) => {
     const view = ctx.query.view === 'history' || ctx.query.view === 'chat' ? ctx.query.view : undefined
     const search = searchFrom(ctx, ['limit', 'offset', 'order', 'archived', 'profile', 'source'])
-    if (view === 'chat') search.set('source', 'web')
-    else search.set('exclude_sources', view === 'history' ? 'cron,ios_group,yaoyao_workspace,web' : 'cron,ios_group,yaoyao_workspace')
+    const offset = Math.max(0, Number(search.get('offset') ?? '0') || 0)
+    const limit = Math.max(1, Math.min(200, Number(search.get('limit') ?? '100') || 100))
+    search.set('offset', String(offset))
+    search.set('limit', String(limit))
+    if (!search.get('order')) search.set('order', 'recent')
+    if (!search.get('archived')) search.set('archived', 'exclude')
+    if (view) search.delete('source')
+    search.set('exclude_sources', 'cron,ios_group,yaoyao_workspace')
     const path = search.get('profile') ? '/api/sessions' : '/api/profiles/sessions'
+    if (view && !dependencies.chatCache) {
+      throw new HttpError(503, 'Chat ownership registry is unavailable', 'chat_registry_unavailable')
+    }
+    if (view === 'history' && dependencies.chatCache) {
+      const owner = dependencies.auth.require(ctx).id
+      const profile = search.get('profile') || ''
+      await withJar(ctx, async (jar) => {
+        const response = await completeSessionList(dependencies.upstream, path, jar, search)
+        sendUpstreamResponse(
+          ctx,
+          dependencies.chatCache!.store.excludeOwnedSessionsFromList(
+            owner,
+            profile,
+            response,
+            { offset, limit },
+          ),
+          jar,
+        )
+      })
+      return
+    }
     if (view !== 'chat') {
       await proxy(ctx, dependencies.upstream, path, { search })
       return
     }
     const profile = search.get('profile') || ''
+    const upstreamSearch = new URLSearchParams(search)
+    upstreamSearch.set('offset', '0')
+    upstreamSearch.set('limit', '100')
     await cachedChatRead(ctx, dependencies, {
       key: chatCacheKey('list', profile, undefined, search),
-      kind: 'list', profile, knownWeb: true,
-      load: jar => dependencies.upstream.request(path, jar, { search }),
+      kind: 'list', profile, ownedList: true, offset, limit,
+      archived: search.get('archived') || 'exclude',
+      load: jar => dependencies.upstream.request(path, jar, { search: upstreamSearch }),
     })
   })
   router.get('/api/app/sessions/:sessionID/messages', async (ctx) => {
@@ -1927,15 +2104,23 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async jar => {
       const profile = search.get('profile') || 'default'
       const owner = dependencies.auth.require(ctx).id
-      requireWritableWebSession(
+      requireWritableOwnedSession(
         await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
         dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
       )
-      const request = body(ctx)
+      const request = writableSessionPatch(body(ctx))
       const requestedProfile = search.get('profile')
       if (requestedProfile) request.profile = requestedProfile
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'PATCH', search, body: request })
       if (response.status >= 200 && response.status < 300) {
+        if (typeof request.title === 'string') {
+          dependencies.chatCache?.store.recordAuthoritativeTitle(
+            owner,
+            profile,
+            id,
+            request.title,
+          )
+        }
         dependencies.chatCache?.store.markListsStale(owner)
         void dependencies.chatCache?.reconcile(owner, profile, id)
       }
@@ -1948,7 +2133,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async jar => {
       const profile = search.get('profile') || 'default'
       const owner = dependencies.auth.require(ctx).id
-      requireWritableWebSession(
+      requireWritableOwnedSession(
         await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
         dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
       )
@@ -1967,21 +2152,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   for (const listPath of ['/api/sessions', '/api/profiles/sessions']) {
     router.get(listPath, async ctx => {
       const search = new URLSearchParams(ctx.querystring)
-      const profile = search.get('profile') || ''
-      const knownWeb = search.get('source') === 'web'
-      if (!knownWeb) {
-        await withJar(ctx, async jar => sendUpstreamResponse(
-          ctx,
-          await dependencies.upstream.request(listPath, jar, { search }),
-          jar,
-        ))
-        return
-      }
-      await cachedChatRead(ctx, dependencies, {
-        key: chatCacheKey('list', profile, undefined, search),
-        kind: 'list', profile, knownWeb: true,
-        load: jar => dependencies.upstream.request(listPath, jar, { search }),
-      })
+      await withJar(ctx, async jar => sendUpstreamResponse(
+        ctx,
+        await dependencies.upstream.request(listPath, jar, { search }),
+        jar,
+      ))
     })
   }
   router.get('/api/sessions/:sessionID/messages', async ctx => {

+ 2 - 0
src/shared/types.ts

@@ -76,6 +76,8 @@ export interface SessionSummary {
   id: string
   profile?: string
   source: string
+  /** Server-owned chat membership. `source` remains provenance only. */
+  owned?: boolean
   title: string
   preview?: string
   model?: string

+ 71 - 2
tests/client/chatModelSync.test.ts

@@ -6,7 +6,10 @@ const realtime = vi.hoisted(() => ({
   eventHandler: undefined as ((event: unknown) => void) | undefined,
   request: vi.fn(),
 }))
-const sessionsApi = vi.hoisted(() => ({ getSession: vi.fn() }))
+const sessionsApi = vi.hoisted(() => ({
+  getSession: vi.fn(),
+  getSessions: vi.fn(),
+}))
 
 vi.mock('@/api/realtime', () => {
   class RpcError extends Error {}
@@ -24,7 +27,7 @@ vi.mock('@/api/sessions', () => ({
   deleteSession: vi.fn(),
   getMessages: vi.fn(),
   getSession: sessionsApi.getSession,
-  getSessions: vi.fn(),
+  getSessions: sessionsApi.getSessions,
   getSessionUnread: vi.fn(),
   markSessionRead: vi.fn(),
   updateSession: vi.fn(),
@@ -45,6 +48,7 @@ describe('chat model realtime synchronization', () => {
     realtime.eventHandler = undefined
     realtime.request.mockReset().mockResolvedValue({})
     sessionsApi.getSession.mockReset()
+    sessionsApi.getSessions.mockReset().mockResolvedValue({ items: [], nextCursor: null })
     setActivePinia(createPinia())
   })
 
@@ -152,4 +156,69 @@ describe('chat model realtime synchronization', () => {
 
     expect(realtime.request).not.toHaveBeenCalled()
   })
+
+  it('debounces global session changes and reloads the visible server projection', async () => {
+    vi.useFakeTimers()
+    try {
+      const chat = useChatStore()
+      sessionsApi.getSessions.mockResolvedValue({
+        items: [{
+          id: 'ios-session', profile: 'alpha', source: 'web', title: 'iOS 新会话',
+          messageCount: 2, toolCallCount: 0, startedAt: 2, updatedAt: 2,
+        }],
+        nextCursor: null,
+      })
+
+      realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
+      realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
+      realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
+
+      expect(sessionsApi.getSessions).not.toHaveBeenCalled()
+      await vi.advanceTimersByTimeAsync(200)
+
+      expect(sessionsApi.getSessions).toHaveBeenCalledTimes(1)
+      expect(sessionsApi.getSessions).toHaveBeenCalledWith('alpha', undefined, 100, 'chat')
+      expect(chat.sessions).toEqual([
+        expect.objectContaining({ id: 'ios-session', title: 'iOS 新会话' }),
+      ])
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it.each(['session.title', 'session.title.updated'])(
+    'applies %s immediately and schedules one authoritative list refresh',
+    async eventType => {
+      vi.useFakeTimers()
+      try {
+        const chat = useChatStore()
+        chat.sessions = [{
+          id: 'session-1', profile: 'alpha', source: 'web', title: '新会话',
+          messageCount: 2, toolCallCount: 0, startedAt: 1, updatedAt: 1,
+        }]
+        chat.routes[routeKey('alpha', 'session-1')] = {
+          route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
+          messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
+          messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
+        }
+        sessionsApi.getSessions.mockResolvedValue({
+          items: [{ ...chat.sessions[0], title: '服务端自动标题' }],
+          nextCursor: null,
+        })
+
+        realtime.eventHandler?.({
+          type: eventType,
+          session_id: 'runtime-1',
+          payload: { session_id: 'session-1', title: '服务端自动标题' },
+        })
+
+        expect(chat.sessions[0].title).toBe('服务端自动标题')
+        expect(sessionsApi.getSessions).not.toHaveBeenCalled()
+        await vi.advanceTimersByTimeAsync(200)
+        expect(sessionsApi.getSessions).toHaveBeenCalledTimes(1)
+      } finally {
+        vi.useRealTimers()
+      }
+    },
+  )
 })

+ 15 - 0
tests/client/sessionModel.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { modelForChoiceId, modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionModel'
 import { normalizeSession } from '@/utils/normalize'
+import { isOwnedChatSession } from '@/utils/sessionOwnership'
 
 const models = [
   { id: 'gpt-5.6-terra', name: 'GPT 5.6 Terra', provider: 'custom' },
@@ -33,6 +34,20 @@ describe('session model selection', () => {
     })).toMatchObject({ model: 'omni', provider: 'custom:tingly' })
   })
 
+  it('uses server ownership rather than source to separate chats from history', () => {
+    const ownedIOS = normalizeSession({
+      id: 'owned-ios', source: 'ios', owned: true, title: '跨端聊天',
+    })
+    const unownedWeb = normalizeSession({
+      id: 'native-web', source: 'web', owned: false, title: '原生历史',
+    })
+
+    expect(isOwnedChatSession(ownedIOS)).toBe(true)
+    expect(isOwnedChatSession(unownedWeb)).toBe(false)
+    expect(isOwnedChatSession({ id: 'draft-local', owned: undefined })).toBe(true)
+    expect(isOwnedChatSession({ id: 'draft-server-history', owned: false })).toBe(false)
+  })
+
   it('finds a chosen model when its provider contains a colon', () => {
     const tinglyModel = { id: 'omni', name: 'Omni', provider: 'Custom:Tingly' }
     expect(modelForChoiceId([tinglyModel], 'Custom:Tingly:omni')).toEqual(tinglyModel)

+ 61 - 0
tests/e2e/workspace-chat.spec.ts

@@ -22,6 +22,67 @@ async function createAgent(page: Page, name: string) {
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
+
+test('an open Web chat list receives cross-client sessions and real title events without reload', async ({
+  page,
+  context,
+}) => {
+  await login(page)
+  const pageAChannelOpened = page.waitForResponse(response =>
+    response.request().method() === 'POST'
+      && response.url().endsWith('/api/realtime/channels'))
+  await page.goto('/chat')
+  await pageAChannelOpened
+  await expect(page.getByRole('button', { name: '新建聊天', exact: true })).toBeVisible()
+
+  const marker = `${Date.now()}-${Math.random().toString(16).slice(2)}`
+  const initialTitle = `跨端新会话 ${marker}`
+  const generatedTitle = `自动标题 ${marker}`
+  const sentinel = `page-a-${marker}`
+  await page.evaluate(value => {
+    ;(window as Window & { __crossClientProjectionSentinel?: string })
+      .__crossClientProjectionSentinel = value
+  }, sentinel)
+
+  const pageB = await context.newPage()
+  const pageBChannelOpened = pageB.waitForResponse(response =>
+    response.request().method() === 'POST'
+      && response.url().endsWith('/api/realtime/channels'))
+  await pageB.goto('/chat')
+  await pageBChannelOpened
+  await pageB.getByRole('button', { name: '新建聊天', exact: true }).click()
+  const pageBComposer = pageB.getByRole('textbox', {
+    name: '输入消息,Enter 发送,Shift + Enter 换行',
+  })
+  await pageBComposer.fill(`[cross-client-title:${initialTitle}] 创建跨端会话`)
+  const pageBSend = pageB.getByRole('button', { name: '发送消息', exact: true })
+  await expect(pageBSend).toBeEnabled()
+  await pageBSend.click()
+  await expect(pageB).toHaveURL(/\/chat\/[^/?]+/)
+  const sessionId = decodeURIComponent(new URL(pageB.url()).pathname.split('/').at(-1) || '')
+  expect(sessionId).not.toMatch(/^draft-/)
+  await expect(pageB.locator('.message--assistant')).toContainText('会话独立保存')
+
+  const sessionRow = page.locator(`.desktop-sidebar .sidebar-item[data-sidebar-id="${sessionId}"]`)
+  await expect(sessionRow).toContainText(initialTitle)
+  expect(await page.evaluate(() =>
+    (window as Window & { __crossClientProjectionSentinel?: string })
+      .__crossClientProjectionSentinel)).toBe(sentinel)
+  await pageB.close()
+
+  const titled = await page.request.post(
+    `http://127.0.0.1:19120/__test/sessions/title?id=${encodeURIComponent(sessionId)}`
+      + `&title=${encodeURIComponent(generatedTitle)}`,
+  )
+  expect(titled.ok(), await titled.text()).toBe(true)
+
+  await expect(sessionRow).toContainText(generatedTitle)
+  await expect(sessionRow).not.toContainText(initialTitle)
+  expect(await page.evaluate(() =>
+    (window as Window & { __crossClientProjectionSentinel?: string })
+      .__crossClientProjectionSentinel)).toBe(sentinel)
+})
+
 test('created roles and editable teams share a durable chat list without plugin calls', async ({
   page,
   context,

+ 50 - 1
tests/fixtures/e2e-web-server.mjs

@@ -1,7 +1,56 @@
-import { rmSync } from 'node:fs'
+import { randomBytes, scryptSync } from 'node:crypto'
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
 
 const testHome = '/tmp/hermes-yaoyao-e2e-home'
+const testUserID = '11111111-1111-4111-8111-111111111112'
 rmSync(testHome, { recursive: true, force: true })
+mkdirSync(testHome, { recursive: true, mode: 0o700 })
+
+// The browser fixture exercises the ownership registry, not the mutable
+// upstream `source` field. Seed only the sessions that were explicitly
+// created by this local Web user; session-history-only deliberately remains
+// unowned even though fake Hermes labels it source=web.
+const salt = randomBytes(16)
+const now = Date.now()
+writeFileSync(`${testHome}/users.json`, JSON.stringify({
+  version: 1,
+  users: [{
+    id: testUserID,
+    username: 'admin',
+    normalizedUsername: 'admin',
+    role: 'admin',
+    enabled: true,
+    mustChangePassword: false,
+    salt: salt.toString('base64'),
+    passwordHash: scryptSync('e2e-password', salt, 32, {
+      N: 2 ** 14,
+      r: 8,
+      p: 1,
+      maxmem: 64 * 1024 * 1024,
+    }).toString('base64'),
+    authVersion: 1,
+    createdAt: now,
+    updatedAt: now,
+  }],
+}), { mode: 0o600 })
+
+const ownedSessions = [
+  ['session-demo', 'yaoyao'],
+  ['session-second', 'yaoyao'],
+  ['session-yaoer', 'yaoer'],
+  ['session-media', 'yaoer'],
+  ['session-user-media', 'yaoer'],
+  ...Array.from({ length: 101 }, (_, index) => [`session-page-${index + 1}`, 'yaoyao']),
+]
+const { ChatCacheStore } = await import('../../dist-server/server/chatCache.js')
+const registry = new ChatCacheStore(testHome, testUserID)
+for (const [sessionID, profile] of ownedSessions) {
+  registry.recordCommand(testUserID, profile, sessionID, 'session.create', {
+    profile,
+    source: 'web',
+  })
+}
+registry.close()
 
 Object.assign(process.env, {
   NODE_ENV: 'production',

+ 3 - 1
tests/fixtures/fake-hermes.mjs

@@ -103,7 +103,9 @@ const profiles = [
 // This intentionally differs from local timestamp/pin ranking. Browser tests
 // verify that the WebUI preserves the ordering returned by 9119.
 const sessions = [
-  { id: 'session-history-only', profile: 'yaoyao', source: 'cli', title: 'Hermes 外部历史', preview: '只读历史记录', message_count: 1, tool_call_count: 0, started_at: now() - 1800, last_active_at: now() - 900, model: 'gpt-5.6', provider: 'openai' },
+  // `source` is mutable display metadata, not ownership proof. This row is
+  // intentionally absent from the Web user's registry and must stay history-only.
+  { id: 'session-history-only', profile: 'yaoyao', source: 'web', title: 'Hermes 外部历史', preview: '只读历史记录', message_count: 1, tool_call_count: 0, started_at: now() - 1800, last_active_at: now() - 900, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600, model: 'gpt-5.5', provider: 'openai' },
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },

+ 52 - 2
tests/fixtures/workspace-server.ts

@@ -19,16 +19,38 @@ const sessions = new Map<
     id: string
     profile: string
     source: string
+    title: string
     messages: Array<{ role: string; content: string; id: string; timestamp: number }>
     running: boolean
   }
 >()
+const latestRuntimeBySession = new Map<string, string>()
 const calls: Array<{ method: string; params: Record<string, unknown> }> = []
 const heldReplies: Array<() => void> = []
 const profileState = new Map([
   ['default', { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
   ['server', { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
 ])
+
+function broadcastUpstreamEvent(
+  type: string,
+  payload: Record<string, unknown>,
+  sessionId?: string,
+  profile = 'default',
+) {
+  const frame = JSON.stringify({
+    method: 'event',
+    params: {
+      type,
+      payload,
+      ...(sessionId ? { session_id: sessionId, profile } : {}),
+    },
+  })
+  for (const socket of wss.clients) {
+    if (socket.readyState === 1) socket.send(frame)
+  }
+}
+
 const upstream = createServer((req, res) => {
   const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
   res.setHeader('content-type', 'application/json')
@@ -48,6 +70,26 @@ const upstream = createServer((req, res) => {
     send({ calls })
     return
   }
+  if (url.pathname === '/__test/sessions/title' && req.method === 'POST') {
+    const id = url.searchParams.get('id')?.trim() || ''
+    const title = url.searchParams.get('title')?.trim() || ''
+    const session = sessions.get(id)
+    const runtimeId = latestRuntimeBySession.get(id)
+    if (!session || !runtimeId || !title) {
+      res.statusCode = 409
+      send({ error: 'session route is not active' })
+      return
+    }
+    session.title = title
+    broadcastUpstreamEvent(
+      'session.title',
+      { session_id: id, title },
+      runtimeId,
+      session.profile,
+    )
+    send({ id, title })
+    return
+  }
   if (url.pathname === '/api/status') {
     send({ auth_required: true, overall: 'ready', gateway_running: true })
     return
@@ -76,7 +118,7 @@ const upstream = createServer((req, res) => {
     send({
       sessions: visible.map((s) => ({
         id: s.id,
-        title: 'Internal',
+        title: s.title,
         source: s.source,
         profile: s.profile,
       })),
@@ -99,7 +141,7 @@ const upstream = createServer((req, res) => {
       id: session.id,
       profile: session.profile,
       source: session.source,
-      title: 'Internal',
+      title: session.title,
       message_count: session.messages.length,
     })
     return
@@ -159,6 +201,7 @@ wss.on('connection', (socket) => {
           id: randomUUID(),
           profile: f.params.profile,
           source: String(f.params.source ?? 'web'),
+          title: String(f.params.title ?? '新对话'),
           messages: [],
           running: false,
         }
@@ -166,6 +209,7 @@ wss.on('connection', (socket) => {
       }
       const runtimeId = randomUUID()
       runtimes.set(runtimeId, stored.id)
+      latestRuntimeBySession.set(stored.id, runtimeId)
       respond({
         session_id: runtimeId,
         stored_session_id: stored.id,
@@ -178,6 +222,12 @@ wss.on('connection', (socket) => {
     const stored = sessions.get(runtimes.get(f.params.session_id) ?? '')
     if (f.method === 'prompt.submit' && stored) {
       stored.running = true
+      const requestedTitle = /^\[cross-client-title:([^\]\r\n]+)\]/
+        .exec(String(f.params.text))?.[1]?.trim()
+      if (requestedTitle) {
+        stored.title = requestedTitle
+        setTimeout(() => broadcastUpstreamEvent('sessions.changed', {}), 0)
+      }
       stored.messages.push({
         id: randomUUID(),
         role: 'user',

+ 366 - 4
tests/server/chatCache.test.ts

@@ -2,6 +2,7 @@
 import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { ChatCacheCoordinator, ChatCacheStore, chatCacheKey } from '../../src/server/chatCache.js'
 import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
@@ -31,6 +32,8 @@ afterEach(() => {
 describe('durable source=web chat cache', () => {
   it('serves a warm list without a second 9119 request and survives restart', async () => {
     const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
     const search = new URLSearchParams({ source: 'web', profile })
     const key = chatCacheKey('list', profile, undefined, search)
     const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web', title: '本地会话' }] }))
@@ -48,8 +51,193 @@ describe('durable source=web chat cache', () => {
     restored.close()
   })
 
+  it('reprojects a pre-ownership list snapshot after restart before serving it', async () => {
+    const f = fixture()
+    const search = new URLSearchParams({ profile, offset: '0', limit: '100' })
+    const key = chatCacheKey('list', profile, undefined, search)
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'ios' })
+    f.store.putSnapshot(owner, key, 'list', profile, undefined, response({
+      sessions: [{ id: sessionID, profile, source: 'ios', title: '旧快照标题' }],
+      total: 1,
+    }), true)
+    f.store.db.prepare(`UPDATE chat_sessions SET authoritative_title=NULL,authoritative_title_at=0
+      WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
+    expect(JSON.parse(f.store.snapshot(owner, key)!.response.body.toString()).sessions[0].owned)
+      .toBeUndefined()
+    f.store.close()
+
+    const restored = new ChatCacheStore(f.home)
+    expect(restored.snapshot(owner, key)?.state).toBe('stale')
+    expect((restored.db.prepare(`SELECT authoritative_title FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { authoritative_title: string }).authoritative_title)
+      .toBe('旧快照标题')
+    const coordinator = new ChatCacheCoordinator(
+      restored,
+      { request: vi.fn() } as unknown as UpstreamServiceSession,
+      'prefer-local',
+    )
+    const projected = await coordinator.read(
+      owner,
+      key,
+      'list',
+      profile,
+      undefined,
+      async () => { throw new Error('9119 offline') },
+      true,
+      { offset: 0, limit: 100 },
+    )
+    expect(projected).toMatchObject({ source: 'local', state: 'stale' })
+    expect(JSON.parse(projected.response.body.toString()).sessions[0]).toMatchObject({
+      id: sessionID,
+      source: 'ios',
+      owned: true,
+    })
+    restored.close()
+  })
+
+  it('adopts the released v0.3.26 registry once without using source for later membership', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-registry-'))
+    homes.push(home)
+    const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
+    legacy.exec(`CREATE TABLE chat_sessions(
+      owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+      runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
+      sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
+      last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
+      PRIMARY KEY(owner,profile,session_id))`)
+    legacy.prepare(`INSERT INTO chat_sessions(
+      owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
+    ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
+      owner,
+      profile,
+      'v026-session',
+      null,
+      'web',
+      JSON.stringify({ id: 'v026-session', profile, source: 'web', title: '已发布旧会话' }),
+      'current',
+      0,
+      0,
+      1_000,
+    )
+    legacy.close()
+
+    const migrated = new ChatCacheStore(home, owner)
+    expect(migrated.ownsSession(owner, profile, 'v026-session')).toBe(true)
+    expect(migrated.db.prepare(`SELECT ownership_evidence FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, 'v026-session'))
+      .toMatchObject({ ownership_evidence: 'legacy-single-user' })
+    expect(migrated.putSnapshot(owner, 'new-source-row', 'detail', profile, 'new-source-row',
+      response({ id: 'new-source-row', profile, source: 'web', title: '未登记新历史' }))).toBe(false)
+    expect(migrated.ownsSession(owner, profile, 'new-source-row')).toBe(false)
+    migrated.close()
+  })
+
+  it('does not adopt ambiguous multi-owner legacy cache rows without command evidence', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-multi-owner-'))
+    homes.push(home)
+    const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
+    legacy.exec(`CREATE TABLE chat_sessions(
+      owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+      runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
+      sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
+      last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
+      PRIMARY KEY(owner,profile,session_id));
+      CREATE TABLE chat_events(
+        owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+        event_seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL,
+        received_at INTEGER NOT NULL, PRIMARY KEY(owner,profile,session_id,event_seq,event_type));`)
+    const insertSession = legacy.prepare(`INSERT INTO chat_sessions(
+      owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
+    ) VALUES(?,?,?,?,?,?,?,?,?,?)`)
+    for (const [legacyOwner, id] of [
+      ['owner-a', 'evidenced'],
+      ['owner-a', 'source-only-a'],
+      ['owner-b', 'source-only-b'],
+    ] as const) {
+      insertSession.run(
+        legacyOwner,
+        profile,
+        id,
+        null,
+        'web',
+        JSON.stringify({ id, profile, source: 'web', title: id }),
+        'current',
+        0,
+        0,
+        1_000,
+      )
+    }
+    legacy.prepare(`INSERT INTO chat_events(
+      owner,profile,session_id,event_seq,event_type,payload,received_at
+    ) VALUES(?,?,?,?,?,?,?)`).run(
+      'owner-a', profile, 'evidenced', 1, 'command:session.create', '{}', 900,
+    )
+    legacy.close()
+
+    const migrated = new ChatCacheStore(home, 'owner-a')
+    expect(migrated.ownsSession('owner-a', profile, 'evidenced')).toBe(true)
+    expect(migrated.ownsSession('owner-a', profile, 'source-only-a')).toBe(false)
+    expect(migrated.ownsSession('owner-b', profile, 'source-only-b')).toBe(false)
+    migrated.close()
+  })
+
+  it('does not adopt one cached owner when the sole local user has another id', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-owner-mismatch-'))
+    homes.push(home)
+    const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
+    legacy.exec(`CREATE TABLE chat_sessions(
+      owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+      runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
+      sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
+      last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
+      PRIMARY KEY(owner,profile,session_id))`)
+    legacy.prepare(`INSERT INTO chat_sessions(
+      owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
+    ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
+      'cached-old-user', profile, 'source-only', null, 'web',
+      JSON.stringify({ id: 'source-only', profile, source: 'web', title: '旧缓存' }),
+      'current', 0, 0, 1_000,
+    )
+    legacy.close()
+
+    const migrated = new ChatCacheStore(home, 'current-sole-user')
+    expect(migrated.ownsSession('cached-old-user', profile, 'source-only')).toBe(false)
+    migrated.close()
+  })
+
+  it('re-enters ownership migration when columns exist but the completion marker is missing', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-interrupted-migration-'))
+    homes.push(home)
+    const interrupted = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
+    interrupted.exec(`CREATE TABLE chat_sessions(
+      owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+      runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
+      authoritative_title TEXT, authoritative_title_at INTEGER NOT NULL DEFAULT 0,
+      owned_at INTEGER, ownership_evidence TEXT,
+      sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
+      last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
+      PRIMARY KEY(owner,profile,session_id))`)
+    interrupted.prepare(`INSERT INTO chat_sessions(
+      owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
+    ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
+      owner, profile, 'interrupted-row', null, 'web',
+      JSON.stringify({ id: 'interrupted-row', profile, source: 'web', title: '可恢复旧会话' }),
+      'current', 0, 0, 1_000,
+    )
+    interrupted.close()
+
+    const recovered = new ChatCacheStore(home, owner)
+    expect(recovered.ownsSession(owner, profile, 'interrupted-row')).toBe(true)
+    expect(recovered.db.prepare("SELECT value FROM chat_meta WHERE key='ownership-registry-v1'").get())
+      .toMatchObject({ value: 'complete' })
+    recovered.close()
+  })
+
   it('materializes complete message pages and keeps owners isolated', () => {
     const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
     f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID,
       response({ id: sessionID, profile, source: 'web', title: '对话' }))
     f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
@@ -70,25 +258,29 @@ describe('durable source=web chat cache', () => {
     f.store.close()
   })
 
-  it('never persists non-Web history', () => {
+  it('never persists unregistered history regardless of source metadata', () => {
     const f = fixture()
     expect(f.store.putSnapshot(owner, 'history', 'detail', profile, 'history-1',
       response({ id: 'history-1', profile, source: 'telegram', title: '历史' }))).toBe(false)
+    expect(f.store.putSnapshot(owner, 'unowned-web', 'detail', profile, 'unowned-web',
+      response({ id: 'unowned-web', profile, source: 'web', title: '未登记 Web 历史' }))).toBe(false)
     expect(f.store.snapshot(owner, 'history')).toBeUndefined()
+    expect(f.store.snapshot(owner, 'unowned-web')).toBeUndefined()
     f.store.close()
   })
 
   it('marks event gaps stale, deduplicates events, and falls back while 9119 is offline', async () => {
     const f = fixture()
     const key = 'detail-key'
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-1')
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
     f.store.putSnapshot(owner, key, 'detail', profile, sessionID,
       response({ id: sessionID, profile, source: 'web', title: '稳定内容' }))
     const frame = { type: 'message.delta', seq: 2, payload: { text: '增量' } }
     f.store.recordEvent(owner, profile, sessionID, frame)
     f.store.recordEvent(owner, profile, sessionID, frame)
-    const count = f.store.db.prepare('SELECT COUNT(*) count FROM chat_events').get() as { count: number }
+    const count = f.store.db.prepare("SELECT COUNT(*) count FROM chat_events WHERE event_type='message.delta'").get() as { count: number }
     expect(count.count).toBe(1)
-    f.store.recordRoute(owner, profile, sessionID, 'runtime-1')
     expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
     expect(f.store.ownsSession('another-owner', profile, sessionID)).toBe(false)
     const route = f.store.db.prepare('SELECT runtime_id FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
@@ -103,8 +295,84 @@ describe('durable source=web chat cache', () => {
     f.store.close()
   })
 
+  it.each(['session.title', 'session.title.updated'])(
+    'projects %s into the owned summary without changing source metadata',
+    (eventType) => {
+      const f = fixture()
+      const ownedSessionID = `owned-${eventType}`
+      const listKey = chatCacheKey('list', profile, undefined, new URLSearchParams({ source: 'web', profile }))
+      f.store.recordRoute(owner, profile, ownedSessionID, 'runtime-owned')
+      f.store.recordCommand(owner, profile, ownedSessionID, 'session.create', { source: 'ios' })
+      f.store.putSnapshot(owner, listKey, 'list', profile, undefined, response({ sessions: [] }), true)
+
+      f.store.recordEvent(owner, profile, ownedSessionID, {
+        type: eventType,
+        payload: { session_id: ownedSessionID, title: '服务端生成的标题' },
+      })
+
+      expect(f.store.snapshot(owner, listKey)?.state).toBe('stale')
+      const merged = f.store.mergeOwnedSessionsIntoList(owner, profile, response({ sessions: [], total: 0 }))
+      expect(JSON.parse(merged.body.toString()).sessions).toContainEqual(expect.objectContaining({
+        id: ownedSessionID,
+        profile,
+        source: 'ios',
+        owned: true,
+        title: '服务端生成的标题',
+      }))
+
+      const staleList = f.store.mergeOwnedSessionsIntoList(owner, profile, response({ sessions: [{
+        id: ownedSessionID, profile, source: 'ios', title: '新对话',
+      }] }))
+      expect(JSON.parse(staleList.body.toString()).sessions[0].title).toBe('服务端生成的标题')
+
+      const titleVersion = f.store.db.prepare(`SELECT authoritative_title_at FROM chat_sessions
+        WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, ownedSessionID) as { authoritative_title_at: number }
+      expect(f.store.putSnapshot(owner, 'delayed-detail', 'detail', profile, ownedSessionID,
+        response({ id: ownedSessionID, profile, source: 'ios', title: '迟到的旧标题' }), false,
+        titleVersion.authoritative_title_at - 1)).toBe(true)
+      const afterDelayedDetail = f.store.markSessionOwnership(owner, profile, ownedSessionID,
+        response({ id: ownedSessionID, profile, source: 'ios', title: '迟到的旧标题' }))
+      expect(JSON.parse(afterDelayedDetail.body.toString()).title).toBe('服务端生成的标题')
+
+      expect(f.store.putSnapshot(owner, 'newer-detail', 'detail', profile, ownedSessionID,
+        response({ id: ownedSessionID, profile, source: 'ios', title: '后续详情标题' }), false,
+        titleVersion.authoritative_title_at + 1)).toBe(true)
+      expect(f.store.putSnapshot(owner, 'regressed-detail', 'detail', profile, ownedSessionID,
+        response({ id: ownedSessionID, profile, source: 'ios', title: '新对话' }))).toBe(true)
+      const afterDetail = f.store.markSessionOwnership(owner, profile, ownedSessionID,
+        response({ id: ownedSessionID, profile, source: 'ios', title: '新对话' }))
+      expect(JSON.parse(afterDetail.body.toString()).title).toBe('后续详情标题')
+      f.store.close()
+    },
+  )
+
+  it('accepts detail and messages for a registered session regardless of source metadata', () => {
+    const f = fixture()
+    const ownedSessionID = 'owned-ios-session'
+    f.store.recordRoute(owner, profile, ownedSessionID, 'runtime-owned')
+    f.store.recordCommand(owner, profile, ownedSessionID, 'session.create', { source: 'ios' })
+
+    expect(f.store.putSnapshot(owner, 'owned-detail', 'detail', profile, ownedSessionID,
+      response({ id: ownedSessionID, profile, source: 'ios', title: 'iOS 权威标题' }))).toBe(true)
+    expect(f.store.putSnapshot(owner, 'owned-messages', 'messages', profile, ownedSessionID, response({
+      session: { id: ownedSessionID, profile, source: 'ios', title: 'iOS 权威标题' },
+      messages: [{ id: 'm-ios', role: 'assistant', content: '已同步' }],
+      pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
+    }))).toBe(true)
+
+    const page = f.store.messagePage(owner, profile, ownedSessionID, 0, 100)
+    expect(JSON.parse(page!.response.body.toString())).toMatchObject({
+      session_id: ownedSessionID,
+      session: { source: 'ios', title: 'iOS 权威标题' },
+      messages: [{ id: 'm-ios' }],
+    })
+    f.store.close()
+  })
+
   it('caches only attachment paths discovered in source=web messages', () => {
     const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
     const sourcePath = '/Users/test/.hermes/profiles/default/images/result.png'
     f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
       session: { id: sessionID, profile, source: 'web' },
@@ -123,6 +391,8 @@ describe('durable source=web chat cache', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-modes-')); homes.push(home)
     const store = new ChatCacheStore(home)
     const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
+    store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
     const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web' }] }))
     const search = new URLSearchParams({ source: 'web' })
     const key = chatCacheKey('list', profile, undefined, search)
@@ -131,8 +401,100 @@ describe('durable source=web chat cache', () => {
     await shadow.read(owner, key, 'list', profile, undefined, load, true)
     expect(load).toHaveBeenCalledTimes(2)
     const upstreamOnly = new ChatCacheCoordinator(store, upstream, 'upstream-only')
-    await upstreamOnly.read(owner, key, 'list', profile, undefined, load, true)
+    const upstreamResult = await upstreamOnly.read(owner, key, 'list', profile, undefined, load, true)
     expect(load).toHaveBeenCalledTimes(3)
+    expect(JSON.parse(upstreamResult.response.body.toString()).sessions).toEqual([
+      expect.objectContaining({ id: sessionID, owned: true }),
+    ])
+    upstreamOnly.route(owner, profile, 'upstream-only-created', 'runtime-upstream-only')
+    expect(store.ownsSession(owner, profile, 'upstream-only-created')).toBe(true)
     store.close()
   })
+
+  it('paginates the complete owner registry before slicing regardless of mixed upstream history', () => {
+    const f = fixture()
+    for (let index = 0; index < 15; index++) {
+      const id = `owned-${index}`
+      const source = index % 2 ? 'ios' : 'web'
+      f.store.recordRoute(owner, profile, id, `runtime-${index}`)
+      f.store.recordCommand(owner, profile, id, 'session.create', { source })
+      expect(f.store.putSnapshot(owner, `detail-${index}`, 'detail', profile, id, response({
+        id,
+        profile,
+        source,
+        title: `Owned ${index}`,
+        last_active: 1_000 + index,
+      }))).toBe(true)
+    }
+    const upstreamSessions = [
+      ...Array.from({ length: 40 }, (_, index) => ({
+        id: `unowned-${index}`,
+        profile,
+        source: index % 2 ? 'ios' : 'web',
+        title: `Unowned ${index}`,
+        last_active: 10_000 + index,
+      })),
+      ...Array.from({ length: 15 }, (_, index) => ({
+        id: `owned-${index}`,
+        profile,
+        source: index % 2 ? 'ios' : 'web',
+        title: '新对话',
+        last_active: 1_000 + index,
+      })),
+    ]
+    const merged = f.store.mergeOwnedSessionsIntoList(
+      owner,
+      profile,
+      response({ sessions: upstreamSessions, total: upstreamSessions.length }),
+      { offset: 5, limit: 4, archived: 'exclude' },
+    )
+    const payload = JSON.parse(merged.body.toString())
+    expect(payload).toMatchObject({ total: 15, offset: 5, limit: 4 })
+    expect(payload.sessions.map((session: { id: string }) => session.id))
+      .toEqual(['owned-9', 'owned-8', 'owned-7', 'owned-6'])
+    expect(payload.sessions.every((session: { owned?: boolean }) => session.owned === true)).toBe(true)
+
+    const history = f.store.excludeOwnedSessionsFromList(
+      owner,
+      profile,
+      response({ sessions: upstreamSessions, total: upstreamSessions.length }),
+      { offset: 7, limit: 5 },
+    )
+    const historyPayload = JSON.parse(history.body.toString())
+    expect(historyPayload).toMatchObject({ total: 40, offset: 7, limit: 5 })
+    expect(historyPayload.sessions.map((session: { id: string }) => session.id))
+      .toEqual(['unowned-7', 'unowned-8', 'unowned-9', 'unowned-10', 'unowned-11'])
+    expect(historyPayload.sessions.some((session: { source: string }) => session.source === 'web')).toBe(true)
+    f.store.close()
+  })
+
+  it('keeps an old pinned owned session on the first page after registry projection', () => {
+    const f = fixture()
+    for (let index = 0; index < 101; index++) {
+      const id = `owned-${index}`
+      f.store.recordRoute(owner, profile, id, `runtime-${index}`)
+      f.store.recordCommand(owner, profile, id, 'session.create', { source: 'ios' })
+      expect(f.store.putSnapshot(owner, `detail-${index}`, 'detail', profile, id, response({
+        id,
+        profile,
+        source: 'ios',
+        title: `Owned ${index}`,
+        last_active: 1_000 + index,
+        pinned: index === 0,
+      }))).toBe(true)
+    }
+
+    const merged = f.store.mergeOwnedSessionsIntoList(
+      owner,
+      profile,
+      response({ sessions: [], total: 0 }),
+      { offset: 0, limit: 100, archived: 'exclude' },
+    )
+    const payload = JSON.parse(merged.body.toString())
+    expect(payload).toMatchObject({ total: 101, offset: 0, limit: 100 })
+    expect(payload.sessions).toHaveLength(100)
+    expect(payload.sessions[0]).toMatchObject({ id: 'owned-0', pinned: true, owned: true })
+    expect(payload.sessions.map((session: { id: string }) => session.id)).not.toContain('owned-1')
+    f.store.close()
+  })
 })

+ 252 - 15
tests/server/chatCacheRoutes.test.ts

@@ -35,6 +35,8 @@ describe('source=web chat cache routes', () => {
   it('uses the Web-owned session registry when Hermes source metadata is stale', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-chat-routes-'))
     homes.push(home)
+    const listQueries: URLSearchParams[] = []
+    let upstreamOwnedTitle = 'Hermes 仍标记为 iOS'
     const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
       const url = new URL(String(input))
       const headers = new Headers(init?.headers)
@@ -50,26 +52,70 @@ describe('source=web chat cache routes', () => {
       if (url.pathname === '/api/profiles') {
         return Response.json({ profiles: [{ name: 'default', is_default: true }] })
       }
+      if (url.pathname === '/api/sessions/search') {
+        const requestedSource = url.searchParams.get('source')
+        const results = [
+          { id: 'unowned-web', profile: 'default', source: 'web', title: '未登记 Web 历史' },
+          { id: 'owned-session', profile: 'default', source: 'ios', title: 'Hermes 仍标记为 iOS' },
+        ].filter(session => !requestedSource || session.source === requestedSource)
+        return Response.json({ results })
+      }
       if (url.pathname === '/api/sessions') {
+        listQueries.push(new URLSearchParams(url.searchParams))
+        // Model the real source filter so this fixture cannot pass by returning
+        // an iOS row to a source=web request.
+        if (url.searchParams.get('source') === 'web') {
+          return Response.json({
+            sessions: [{ id: 'unowned-web', profile: 'default', source: 'web', title: '未登记 Web 历史' }],
+            total: 1,
+          })
+        }
         return Response.json({
-          sessions: [{
-            id: 'owned-session',
-            profile: 'default',
-            source: 'ios',
-            title: 'Hermes 仍标记为 iOS',
-          }],
-          total: 1,
+          sessions: [
+            {
+              id: 'owned-session',
+              profile: 'default',
+              source: 'ios',
+              title: 'Hermes 仍标记为 iOS',
+            },
+            {
+              id: 'history-only',
+              profile: 'default',
+              source: 'ios',
+              title: '未登记历史',
+            },
+            {
+              id: 'unowned-web',
+              profile: 'default',
+              source: 'web',
+              title: '未登记 Web 历史',
+            },
+          ],
+          total: 3,
         })
       }
       if (url.pathname === '/api/sessions/owned-session') {
-        if (init?.method === 'PATCH') return Response.json({ ok: true })
+        if (init?.method === 'PATCH') {
+          upstreamOwnedTitle = String(JSON.parse(String(init.body)).title ?? upstreamOwnedTitle)
+          return Response.json({ ok: true })
+        }
         return Response.json({
           id: 'owned-session',
           profile: 'default',
           source: 'ios',
-          title: 'Hermes 仍标记为 iOS',
+          title: upstreamOwnedTitle,
         })
       }
+      if (url.pathname === '/api/sessions/owned-session/messages') {
+        return Response.json({
+          session: { id: 'owned-session', profile: 'default', source: 'ios', title: 'Hermes 仍标记为 iOS' },
+          messages: [{ id: 'owned-message', role: 'assistant', content: '已同步' }],
+          pagination: { total: 1, returned: 1, offset: 0, limit: 100, has_more: false },
+        })
+      }
+      if (url.pathname === '/api/sessions/history-only') {
+        return Response.json({ id: 'history-only', profile: 'default', source: 'ios', title: '未登记历史' })
+      }
       return Response.json({ error: 'missing fixture' }, { status: 404 })
     })
     const runtime = createApplication({ config: config(home), fetchImpl })
@@ -86,22 +132,210 @@ describe('source=web chat cache routes', () => {
       'owned-session',
       'runtime-owned',
     )
+    runtime.chatCache!.store.recordCommand(
+      owner,
+      'default',
+      'owned-session',
+      'session.create',
+      { source: 'ios' },
+    )
+    await runtime.chatCache!.reconcile(owner, 'default', 'owned-session')
 
     const list = await agent
       .get('/api/app/sessions?view=chat&profile=default&limit=100')
       .set('Host', host)
       .expect(200)
-    expect(list.body.sessions).toContainEqual(expect.objectContaining({
+    expect(list.body.sessions).toEqual([expect.objectContaining({
       id: 'owned-session',
-      source: 'web',
+      source: 'ios',
+      owned: true,
       title: 'Hermes 仍标记为 iOS',
-    }))
+    })])
 
+    const history = await agent
+      .get('/api/app/sessions?view=history&profile=default&limit=100')
+      .set('Host', host)
+      .expect(200)
+    expect(history.body.sessions.map((session: { id: string }) => session.id).sort())
+      .toEqual(['history-only', 'unowned-web'])
+    expect(listQueries.length).toBeGreaterThan(0)
+    expect(listQueries.every(query => !query.has('source'))).toBe(true)
+    expect(listQueries.every(query => !query.get('exclude_sources')?.split(',').includes('web'))).toBe(true)
+    expect(listQueries.every(query => query.get('order') === 'recent')).toBe(true)
+    expect(listQueries.every(query => query.get('archived') === 'exclude')).toBe(true)
+
+    const ownedDetail = await agent.get('/api/app/sessions/owned-session?profile=default')
+      .set('Host', host).expect(200)
+    expect(ownedDetail.body).toMatchObject({ id: 'owned-session', source: 'ios', owned: true })
+    const historyDetail = await agent.get('/api/app/sessions/history-only?profile=default')
+      .set('Host', host).expect(200)
+    expect(historyDetail.body).toMatchObject({ id: 'history-only', source: 'ios', owned: false })
+    const ownedMessages = await agent
+      .get('/api/app/sessions/owned-session/messages?profile=default&offset=0&limit=100')
+      .set('Host', host).expect(200)
+    expect(ownedMessages.body).toMatchObject({
+      owned: true,
+      session: { id: 'owned-session', source: 'ios', owned: true },
+      messages: [{ id: 'owned-message' }],
+    })
+
+    const searched = await agent.get('/api/app/sessions/search?q=Hermes&profile=default')
+      .set('Host', host).expect(200)
+    expect(searched.body.results).toEqual([
+      expect.objectContaining({ id: 'unowned-web', source: 'web', owned: false }),
+      expect.objectContaining({ id: 'owned-session', source: 'ios', owned: true }),
+    ])
+    const searchedChat = await agent
+      .get('/api/app/sessions/search?q=Hermes&profile=default&view=chat&source=web&limit=1')
+      .set('Host', host)
+      .expect(200)
+    expect(searchedChat.body).toMatchObject({
+      total: 1,
+      results: [expect.objectContaining({ id: 'owned-session', owned: true })],
+    })
+    const searchedHistory = await agent
+      .get('/api/app/sessions/search?q=Hermes&profile=default&view=history&limit=1')
+      .set('Host', host)
+      .expect(200)
+    expect(searchedHistory.body).toMatchObject({
+      total: 1,
+      results: [expect.objectContaining({ id: 'unowned-web', owned: false })],
+    })
+    const upstreamSearchRequests = fetchImpl.mock.calls
+      .map(([input]) => new URL(String(input)))
+      .filter(url => url.pathname === '/api/sessions/search')
+    expect(upstreamSearchRequests.every(url => url.searchParams.get('limit') === '100')).toBe(true)
+    expect(upstreamSearchRequests.every(url => !url.searchParams.has('source'))).toBe(true)
+
+    runtime.chatCache!.store.recordEvent(owner, 'default', 'owned-session', {
+      type: 'session.title',
+      payload: { session_id: 'owned-session', title: '事件权威标题' },
+    })
+    const eventTitledSearch = await agent
+      .get('/api/app/sessions/search?q=Hermes&profile=default&view=chat')
+      .set('Host', host)
+      .expect(200)
+    expect(eventTitledSearch.body.results).toEqual([
+      expect.objectContaining({ id: 'owned-session', title: '事件权威标题', owned: true }),
+    ])
+
+    const forbiddenSourcePatch = await agent.patch('/api/app/sessions/owned-session?profile=default')
+      .set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', setup.body.csrfToken)
+      .send({ source: 'web' })
+      .expect(400)
+    expect(forbiddenSourcePatch.body.code).toBe('invalid_session_patch')
     await agent.patch('/api/app/sessions/owned-session?profile=default')
       .set('Host', host).set('Origin', origin)
       .set('X-CSRF-Token', setup.body.csrfToken)
       .send({ title: 'Web 可继续管理' })
       .expect(200)
+    const patchCalls = fetchImpl.mock.calls.filter(([, init]) => init?.method === 'PATCH')
+    expect(patchCalls).toHaveLength(1)
+    expect(JSON.parse(String(patchCalls[0]?.[1]?.body))).toEqual({
+      title: 'Web 可继续管理',
+      profile: 'default',
+    })
+    const immediateRenamedDetail = await agent
+      .get('/api/app/sessions/owned-session?profile=default')
+      .set('Host', host)
+      .expect(200)
+    expect(immediateRenamedDetail.body.title).toBe('Web 可继续管理')
+
+    // A reconcile may complete immediately after the mutation; it must retain
+    // the synchronously published title.
+    await runtime.chatCache!.reconcile(owner, 'default', 'owned-session')
+    const reconciledDetail = await agent
+      .get('/api/app/sessions/owned-session?profile=default')
+      .set('Host', host)
+      .expect(200)
+    expect(reconciledDetail.body.title).toBe('Web 可继续管理')
+    const renamedList = await agent
+      .get('/api/app/sessions?view=chat&profile=default&limit=100')
+      .set('Host', host)
+      .expect(200)
+    expect(renamedList.body.sessions).toContainEqual(expect.objectContaining({
+      id: 'owned-session',
+      title: 'Web 可继续管理',
+      owned: true,
+    }))
+    const renamedSearch = await agent
+      .get('/api/app/sessions/search?q=Hermes&profile=default&view=chat')
+      .set('Host', host)
+      .expect(200)
+    expect(renamedSearch.body.results).toEqual([
+      expect.objectContaining({ id: 'owned-session', title: 'Web 可继续管理', owned: true }),
+    ])
+  })
+
+  it('paginates chat and history after projecting a multi-page mixed upstream list', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-pagination-routes-'))
+    homes.push(home)
+    const upstreamSessions = Array.from({ length: 12 }, (_, index) => [
+      { id: `unowned-${index}`, profile: 'default', source: index % 2 ? 'ios' : 'web', title: `Unowned ${index}` },
+      ...(index < 8 ? [{ id: `owned-${index}`, profile: 'default', source: index % 2 ? 'web' : 'ios', title: `Owned ${index}` }] : []),
+    ]).flat()
+    const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+      const url = new URL(String(input))
+      const headers = new Headers(init?.headers)
+      if (url.pathname === '/api/status') return Response.json({ auth_required: false })
+      if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="pagination-token-1234567890123456";</script>')
+      if (headers.get('x-hermes-session-token') !== 'pagination-token-1234567890123456') {
+        return Response.json({}, { status: 401 })
+      }
+      if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (url.pathname === '/api/sessions') {
+        const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0))
+        const limit = 5
+        return Response.json({
+          sessions: upstreamSessions.slice(offset, offset + limit),
+          total: upstreamSessions.length,
+          offset,
+          limit,
+        })
+      }
+      return Response.json({ error: 'missing fixture' }, { status: 404 })
+    })
+    const runtime = createApplication({ config: config(home), fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+    const setup = await agent.post('/api/app/setup').set('Host', host)
+      .set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
+      .send({ username: 'owner', password: 'fixture-password' }).expect(200)
+    const owner = String(setup.body.user.id)
+    for (let index = 0; index < 8; index++) {
+      const id = `owned-${index}`
+      runtime.chatCache!.store.recordRoute(owner, 'default', id, `runtime-${index}`)
+      runtime.chatCache!.store.recordCommand(owner, 'default', id, 'session.create', {
+        source: index % 2 ? 'web' : 'ios',
+      })
+      runtime.chatCache!.store.putSnapshot(owner, `detail-${index}`, 'detail', 'default', id, {
+        status: 200,
+        headers: new Headers({ 'content-type': 'application/json' }),
+        body: Buffer.from(JSON.stringify({
+          id,
+          profile: 'default',
+          source: index % 2 ? 'web' : 'ios',
+          title: `Owned ${index}`,
+          last_active: 1_000 + index,
+        })),
+      })
+    }
+
+    const chat = await agent
+      .get('/api/app/sessions?view=chat&profile=default&offset=2&limit=3')
+      .set('Host', host).expect(200)
+    expect(chat.body).toMatchObject({ total: 8, offset: 2, limit: 3 })
+    expect(chat.body.sessions.map((session: { id: string }) => session.id))
+      .toEqual(['owned-5', 'owned-4', 'owned-3'])
+
+    const history = await agent
+      .get('/api/app/sessions?view=history&profile=default&offset=4&limit=4')
+      .set('Host', host).expect(200)
+    expect(history.body).toMatchObject({ total: 12, offset: 4, limit: 4 })
+    expect(history.body.sessions.map((session: { id: string }) => session.id))
+      .toEqual(['unowned-4', 'unowned-5', 'unowned-6', 'unowned-7'])
   })
 
   it('serves warm and restarted chat reads locally while history stays upstream-only', async () => {
@@ -115,11 +349,11 @@ describe('source=web chat cache routes', () => {
       if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="cache-token-1234567890123456";</script>')
       if (headers.get('x-hermes-session-token') !== 'cache-token-1234567890123456') return Response.json({}, { status: 401 })
       if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
-      if (url.pathname === '/api/sessions' && url.searchParams.get('source') === 'web') {
+      if (url.pathname === '/api/sessions') {
         counts.list++
         return Response.json({ sessions: [{ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' }] })
       }
-      if (url.pathname === '/api/profiles/sessions' && url.searchParams.get('exclude_sources')?.includes('web')) {
+      if (url.pathname === '/api/profiles/sessions') {
         counts.history++
         return Response.json({ sessions: [{ id: 'history-only', source: 'telegram', title: '只读历史' }] })
       }
@@ -148,6 +382,9 @@ describe('source=web chat cache routes', () => {
     const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
       .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
     const sessionCookie = cookie(setup)
+    const owner = String(setup.body.user.id)
+    first.chatCache!.store.recordRoute(owner, 'default', 'session-web', 'runtime-web')
+    first.chatCache!.store.recordCommand(owner, 'default', 'session-web', 'session.create', { source: 'web' })
 
     const listPath = '/api/app/sessions?view=chat&profile=default&limit=100'
     await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
@@ -184,7 +421,7 @@ describe('source=web chat cache routes', () => {
     const offlineMedia = await request(restarted.app.callback()).get(mediaPath).set('Host', host).set('Cookie', sessionCookie)
       .expect('X-Yaoyao-Data-Source', 'local').expect(200)
     expect(Buffer.from(offlineMedia.body).toString()).toBe('png-bytes')
-    expect(offlineFetch).not.toHaveBeenCalled()
+    expect(offlineFetch).toHaveBeenCalledTimes(1)
     await request(restarted.app.callback()).get('/api/app/sessions?view=history').set('Host', host).set('Cookie', sessionCookie)
       .expect(502)
     expect(offlineFetch).toHaveBeenCalled()

+ 32 - 0
tests/server/realtimeApi.test.ts

@@ -51,6 +51,7 @@ describe('native Hermes chat and history are separated on 15300', () => {
       .set('Origin', origin).set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({ username: 'owner', password: 'fixture-password' }).expect(200)
     const csrf = setup.body.csrfToken
+    const owner = String(setup.body.user.id)
 
     const capabilities = await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(200)
     expect(capabilities.body.channels).toEqual(['chat'])
@@ -75,6 +76,37 @@ describe('native Hermes chat and history are separated on 15300', () => {
     expect(pairedWrite.body.code).toBe('native_sessions_read_only')
     expect(upstreamCalls).toBeGreaterThan(beforeWrites)
     expect(upstreamWrites).toBe(beforeUpstreamWrites)
+
+    const unownedWeb = await agent.patch('/api/app/sessions/session-web?profile=default')
+      .set('Host', '127.0.0.1:15300').set('Origin', origin).set('X-CSRF-Token', csrf)
+      .send({ title: '不能靠 source 修改' }).expect(410)
+    expect(unownedWeb.body.code).toBe('native_sessions_read_only')
+    const ownershipGate = runtime.realtime as unknown as {
+      canResume(profile: string, sessionID: string, jar?: unknown, owner?: string): Promise<boolean>
+    }
+    expect(await ownershipGate.canResume('default', 'session-web', undefined, owner)).toBe(false)
+    const deviceOwner = `device:${paired.device.id}`
+    expect(await ownershipGate.canResume('default', 'session-web', undefined, deviceOwner)).toBe(false)
+
+    runtime.chatCache!.store.recordRoute(deviceOwner, 'default', 'device-session', 'runtime-device')
+    expect(await ownershipGate.canResume('default', 'device-session', undefined, deviceOwner)).toBe(true)
+    expect(await ownershipGate.canResume('default', 'device-session', undefined, owner)).toBe(false)
+    const anotherPairing = runtime.pairings.create('another_device=session')
+    const anotherDevice = runtime.pairings.claim({
+      pairingID: anotherPairing.id,
+      secret: anotherPairing.secret,
+      deviceName: 'another history fixture',
+    })
+    expect(await ownershipGate.canResume(
+      'default',
+      'device-session',
+      undefined,
+      `device:${anotherDevice.device.id}`,
+    )).toBe(false)
+
+    runtime.chatCache!.store.recordRoute(owner, 'default', 'session-web', 'runtime-web')
+    runtime.chatCache!.store.recordCommand(owner, 'default', 'session-web', 'session.create', { source: 'ios' })
+    expect(await ownershipGate.canResume('default', 'session-web', undefined, owner)).toBe(true)
     await agent.patch('/api/app/sessions/session-web?profile=default').set('Host', '127.0.0.1:15300')
       .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '可修改的聊天' }).expect(200)
     expect(upstreamWrites).toBe(beforeUpstreamWrites + 1)

+ 110 - 1
tests/server/realtimeBroker.test.ts

@@ -54,11 +54,114 @@ const resume = { method: 'session.resume', params: { session_id: 'stored-1', pro
 describe('realtime broker', () => {
   it('rejects resuming a history-only session before opening an upstream route', async () => {
     const f = await fixture()
+    const routes: string[] = []
+    f.broker.onNativeRoute = (_owner, _profile, stored) => routes.push(stored)
     const principal = { ...f.principal('alice'), canResume: async () => false }
     const channel = await f.broker.create(principal, 'chat')
     const receipt = await f.broker.command(channel, 'history-resume', resume)
     expect(receipt).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
     expect(f.commands).toEqual([])
+    expect(routes).toEqual([])
+  })
+  it('registers and resumes sessions only within one stable device owner', async () => {
+    const f = await fixture()
+    const owned = new Set<string>()
+    const commands: Array<{ owner: string; stored: string; method: string }> = []
+    const events: Array<{ owner: string; stored: string; type: string }> = []
+    const ownershipKey = (owner: string, profile: string, stored: string) =>
+      `${owner}\u0000${profile}\u0000${stored}`
+    f.broker.onNativeRoute = (owner, profile, stored) => {
+      owned.add(ownershipKey(owner, profile, stored))
+    }
+    f.broker.onNativeCommand = (owner, _profile, stored, method) => {
+      commands.push({ owner, stored, method })
+    }
+    f.broker.onNativeEvent = (owner, _profile, stored, frame) => {
+      events.push({ owner, stored, type: String(frame.type) })
+    }
+    const device = (id: string): RealtimePrincipal => ({
+      ...f.principal(`device:${id}`),
+      upstreamKey: `device-service:${id}`,
+      paired: true,
+      canResume: async (profile, stored) =>
+        owned.has(ownershipKey(`device:${id}`, profile, stored)),
+    })
+
+    const deviceA = device('device-a')
+    const first = await f.broker.create(deviceA, 'chat')
+    const created = await f.broker.command(first, 'device-create', {
+      method: 'session.create', params: { profile: 'default', source: 'web' },
+    })
+    expect(created.state).toBe('confirmed')
+    expect(owned).toContain(ownershipKey('device:device-a', 'default', 'stored-1'))
+    expect(commands).toContainEqual({ owner: 'device:device-a', stored: 'stored-1', method: 'session.create' })
+    f.emit('message.delta', { text: 'device event' })
+    await vi.waitFor(() => expect(events).toContainEqual({
+      owner: 'device:device-a', stored: 'stored-1', type: 'message.delta',
+    }))
+
+    const sameDevice = await f.broker.create(device('device-a'), 'chat')
+    expect((await f.broker.command(sameDevice, 'same-device-resume', resume)).state).toBe('confirmed')
+    const unowned = await f.broker.command(sameDevice, 'source-web-unowned', {
+      method: 'session.resume', params: { session_id: 'unowned-source-web', profile: 'default' },
+    })
+    expect(unowned).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
+
+    const anotherDevice = await f.broker.create(device('device-b'), 'chat')
+    expect(await f.broker.command(anotherDevice, 'other-device-resume', resume))
+      .toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
+    const user = {
+      ...f.principal('user:alice:1'),
+      upstreamKey: 'user-service',
+      canResume: async (profile: string, stored: string) =>
+        owned.has(ownershipKey('alice', profile, stored)),
+    }
+    const userChannel = await f.broker.create(user, 'chat')
+    expect(await f.broker.command(userChannel, 'user-resume-device-session', resume))
+      .toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
+  })
+  it('broadcasts a route title as an owner-scoped session list change', async () => {
+    const f = await fixture()
+    const globalOwners: string[] = []
+    f.broker.onNativeGlobalEvent = (owner, type) => {
+      if (type === 'sessions.changed') globalOwners.push(owner)
+    }
+    const routedPrincipal = { ...f.principal('user:alice:1'), canResume: async () => true }
+    const routed = await f.broker.create(routedPrincipal, 'chat')
+    const listOnly = await f.broker.create(f.principal('user:alice:1'), 'chat')
+    const anotherUser = await f.broker.create(f.principal('user:bob:1'), 'chat')
+    await f.broker.command(routed, 'owner-title-route', resume)
+    const listFrames: StreamEntry[] = []
+    const otherFrames: StreamEntry[] = []
+    f.broker.subscribe(listOnly, undefined, entry => listFrames.push(entry))
+    f.broker.subscribe(anotherUser, undefined, entry => otherFrames.push(entry))
+
+    f.emit('session.title', { session_id: 'stored-1', title: '跨端标题' })
+
+    await vi.waitFor(() => expect(listFrames.some(entry =>
+      entry.data.includes('sessions.changed'))).toBe(true))
+    expect(otherFrames.some(entry => entry.data.includes('sessions.changed'))).toBe(false)
+    expect(globalOwners).toEqual(['alice'])
+  })
+  it('preserves restricted create provenance and never forwards source on resume', async () => {
+    const f = await fixture()
+    const channel = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(channel, 'create-ios', {
+      method: 'session.create', params: { profile: 'default', source: 'ios' },
+    })
+    await f.broker.command(channel, 'create-web-default', {
+      method: 'session.create', params: { profile: 'default' },
+    })
+    await f.broker.command(channel, 'resume-with-client-source', {
+      method: 'session.resume', params: { session_id: 'stored-1', profile: 'default', source: 'ios' },
+    })
+    const openFrames = f.commands.filter(frame => frame.method === 'session.create' || frame.method === 'session.resume')
+    expect(openFrames[0]?.params.source).toBe('ios')
+    expect(openFrames[1]?.params.source).toBe('web')
+    expect(openFrames[2]?.params).not.toHaveProperty('source')
+    expect(() => f.broker.command(channel, 'create-invalid-source', {
+      method: 'session.create', params: { profile: 'default', source: 'telegram' },
+    })).toThrow(/source must be web or ios/)
   })
   it('shares upstream across subscribers and isolates unregistered routes', async () => {
     const f = await fixture()
@@ -119,13 +222,19 @@ describe('realtime broker', () => {
     b.close()
   })
   it('registers branch identities without losing the original subscription', async () => {
-    const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
+    const f = await fixture()
+    const ownershipCommands: Array<{ owner: string; stored: string; method: string }> = []
+    f.broker.onNativeCommand = (owner, _profile, stored, method) => {
+      ownershipCommands.push({ owner, stored, method })
+    }
+    const c = await f.broker.create(f.principal('user:alice:1'), 'chat')
     await f.broker.command(c, 'open', resume)
     const branch = await f.broker.command(c, 'branch', { method: 'session.branch', params: { session_id: 'runtime-1' } })
     expect(branch.state).toBe('confirmed')
     const sent = await f.broker.command(c, 'branched-prompt', { method: 'prompt.submit', params: { session_id: 'runtime-branch', text: 'branch' } })
     expect(sent.state).toBe('confirmed')
     expect(c.routes.size).toBe(2)
+    expect(ownershipCommands).toContainEqual({ owner: 'alice', stored: 'stored-branch', method: 'session.branch' })
   })
   it('does not let guessed approval IDs escape a subscribed session', async () => {
     const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')