Browse Source

feat: 会话状态隔离 + 账号密码登录 + 新容器接入文档

## 会话状态(左侧转圈)
- 修复房间号子串匹配导致"一条在跑、全房间都转":改为精确判等
- ✅ reaction 退出左侧判断(容器确认它只代表单条消息发完)
- 左侧判定收窄为纯会话级:容器 running / 消息流 / 本地标记
- 本地"刚发出"标记 TTL 60s → 15s(实测容器 5s 报 running)

## 一房间一会话
- 新建会话开独立房间(实测建房 0.6~0.7s,容器 3s 自动进房)
- 建房完成后再进页面,避免首条消息落进共享房间
- 打开会话时优先使用会话自己的 roomId

## 登录
- 新增账号密码登录(POST /parse/login),与验证码/Token 并存
- session 过期时间持久化 + 启动时本地校验

## 历史会话
- 按会话回溯 timeline(先等 sync 落地,命中即停)
- 空态三态分明:新鲜 / 未加载 / 空壳

## 性能
- initialSyncLimit 50 → 5(sync 1593ms → 451ms)
- 修复刷新后大量历史会话同时转 10~15 秒

## 文档
- 新容器接入优化文档(含四阶段提示词、五类形态判定、9 个实测坑)
- 6 份根因分析/实施记录

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 14 hours ago
parent
commit
da57ffd691
31 changed files with 3967 additions and 112 deletions
  1. 134 0
      docs/00-tracking/2026-11-12-lj恢复与左侧转圈修复.md
  2. 146 0
      docs/00-tracking/2026-11-12-一房间一会话实施记录.md
  3. 208 0
      docs/00-tracking/2026-11-12-会话隔离架构分析与方案.md
  4. 153 0
      docs/00-tracking/2026-11-12-历史会话打不开-根因与修复.md
  5. 154 0
      docs/00-tracking/2026-11-12-左侧转圈修复与一房间一会话.md
  6. 223 0
      docs/00-tracking/2026-11-12-左侧转圈现状分析与方案.md
  7. 260 0
      docs/00-tracking/2026-11-12-环境启动与全链路诊断.md
  8. 65 0
      docs/00-tracking/操作追踪.md
  9. 4 3
      docs/03-interface/README-容器接入文档索引.md
  10. 886 0
      docs/03-interface/新容器接入优化-对齐lj-test基线-20261112.md
  11. 54 13
      src/app/components/domain/new-session-modal/new-session-modal.ts
  12. 68 35
      src/app/components/domain/session-tree/session-tree.ts
  13. 68 0
      src/app/core/agent-state.spec.ts
  14. 72 4
      src/app/core/agent-state.ts
  15. 19 0
      src/app/core/api/fmode-api.ts
  16. 14 0
      src/app/core/api/mock-api.ts
  17. 68 0
      src/app/core/api/parse-api.ts
  18. 23 0
      src/app/core/chat-session.service.ts
  19. 120 4
      src/app/core/chat.service.spec.ts
  20. 424 10
      src/app/core/chat.service.ts
  21. 32 0
      src/app/core/matrix-client.service.spec.ts
  22. 241 10
      src/app/core/matrix-client.service.ts
  23. 52 0
      src/app/core/session.service.spec.ts
  24. 52 2
      src/app/core/session.service.ts
  25. 175 13
      src/app/core/task-pulse.service.ts
  26. 23 1
      src/app/desktop/pages/chat/chat.ts
  27. 15 1
      src/app/desktop/shell/desktop-shell.ts
  28. 12 1
      src/app/mobile/pages/chat/chat.ts
  29. 80 3
      src/app/mobile/pages/login/login.ts
  30. 7 1
      src/app/mobile/shell/mobile-shell.ts
  31. 115 11
      src/app/pages/login/login.ts

+ 134 - 0
docs/00-tracking/2026-11-12-lj恢复与左侧转圈修复.md

@@ -0,0 +1,134 @@
+# lj 容器恢复可用 & 左侧状态转圈修复(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 起因 | 用户反馈「同事用不了 lj」+「左侧对话条目点开就停止转圈,不点开一直转」 |
+| 结论 | ① lj 之前**容器没启动**,启动后同事已可正常使用(实测通过);② 左侧转圈是 App 端 4 处缺陷叠加,已修复 |
+| 实测容器 | `agent-lj`(`@k0nrevcfik:fmode.cn`)、`agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`) |
+| 验证 | `ng test` **1686 SUCCESS**;`ng build --configuration development` 通过;`tsc` 通过 |
+
+---
+
+## 一、lj 为什么「又能用了」
+
+### 1.1 实测时间线(lj 绑定房间 `!Tit52qG6C1InEmwpLJd43lWXfgrcIqcbvvuJ0ckLSUs`)
+
+| 相对时间 | 事件 |
+|---|---|
+| -677 分钟 | 正常对话(天气查询,✅ 正常) |
+| **-544 分钟** | `Dropped a message from unrecognized matrix user pnfapmsbhs` ← **同事被拒的铁证** |
+| -542 ~ -529 分钟 | 连续 5 条 `Hermes is shutting down — your current task will be interrupted` |
+| -6.7 / -4.5 / -2.7 分钟 | 主人发的 3 条提问,**0 条回复、0 个 reaction** |
+| -2.2 分钟 | 主人发「你好」,**仍 0 响应** |
+| **-1 分钟** | 用户启动容器 / 或平台恢复 → 同事「你好」得到 `👀 → 正文 → ✅` |
+
+**判定:lj 在 11-11 17:19Z 之后一直没在跑。**
+`xlosi3m46q` 的 `heartbeatAt` 停在同一时刻(8.9 小时前),`status` 停在 `running` 是最后一次上报的残留值。
+
+### 1.2 恢复后的实测(同事身份,2026-11-12)
+
+用同事本人 token 真实发「你好」,容器事件序列完全正常:
+
+```
+👀 reaction → 「你好!有什么我可以帮你的吗?」 → ✅ reaction
+```
+
+随后同事发「用 html 帮我做明天南昌天气」也完整跑完(工具过程 + 正文 + 交付文件 + ✅)。
+
+**结论:准入规则本身没问题(`GATEWAY_ALLOW_ALL_USERS=true` 生效),之前是容器进程不在。**
+
+### 1.3 为什么同事能登录(凭据派生规则,重要)
+
+排查时踩了坑,记下来避免重复:
+
+```
+用户 Matrix 密码 = sha256("fmode-mx:" + 该用户自己的 FMODE_SESSION_TOKEN) 前 24 位
+```
+
+- 依据:`cloud/functions/fmodeagent-matrix-provision.js:187 _mxPassword()`
+- **sessionToken 是随机 `r:xxxx` 串,不能从 mxid 推导**。我最初按「`r:` + mxid localpart」猜,
+  三次都返回 `Invalid identifier or password` —— 必须拿到用户本人的 token。
+- 拿到同事 token 后,`POST /_matrix/client/v3/login` 即可以其本人身份发消息(不是冒充)。
+
+---
+
+## 二、左侧转圈:根因(4 处叠加)
+
+用户原话:「我点开他就停止,不点开就转。应该是他在输出就转动,没有回复就停止。」
+
+| # | 缺陷 | 位置 | 后果 |
+|---|---|---|---|
+| **R1** | 只有**当前打开的会话**才用 ✅ reaction 收尾 | `session-tree.ts:2515` `if (this.isCurrentSession(s) && ...)` | 后台会话收不到结束信号,只能等容器上报过期 |
+| **R2** | 陈旧的 `idle` 记录把刚发出的会话判成空闲 | `agent-state.ts:sessionRunning()` | 刚发消息**一下都不转**(与用户描述相反的那一半) |
+| **R3** | 本地标记只在时间流逝后失效,但模板不重算 | `session-tree.ts` 模板只读信号 | 消息停了转圈也不停 |
+| **R4** | 左侧没有「本会话是否真的在输出」的信号 | 全局 `noteMessage()` 是**容器级**的 | 多会话时粒度不对 |
+
+### R2 的实测证据
+
+```
+容器 currentSessions: {sessionKey:"$approot_mwbwaue1_...", status:"idle",
+                      turnStartAt:1794451716192, updatedAt:1794451717201}
+turnStartAt → updatedAt 只差 1009ms  → 这条 idle 是「上一轮收尾那一刻」写的
+而用户本轮提问发生在 ~30 秒之后     → 它比本轮还旧,却被当成权威结论
+```
+
+---
+
+## 三、修复内容(6 个文件)
+
+| 文件 | 改动 |
+|---|---|
+| `core/agent-state.ts` | `sessionRunning()` 新增第 6 参 `localStartAt`:**`idle` 记录的 `updatedAt` 早于本地发出时刻 → 不采信**,返回 `undefined` 让调用方回落本地信号 |
+| `core/matrix-client.service.ts` | `onTurnReaction()` 回调新增 `targetId`(`m.relates_to.event_id`),供反推会话 |
+| `core/chat.service.ts` | 收到 ✅ 时用 `_sessionsOfMessage()` 从房间 timeline 反推会话 → `clearSessionLocalStartFor()` 立刻清标记(**不再依赖「当前打开的是哪条」**);`agentIdle` 也传本地时刻 |
+| `core/task-pulse.service.ts` | 新增 `noteSessionAgentMessage()` / `sessionIsWriting()`(**会话级**最后一条 agent 消息);新增 `clearSessionLocalStartFor()`;`describeOf` 传本地时刻 |
+| `components/domain/session-tree/session-tree.ts` | 模板读 `_timeTick` 让时间流逝后重算;`sessionRunningOf()` 加「真实消息流」判定并传本地时刻 |
+| `core/agent-state.spec.ts` | 新增 3 条回归用例,固定「陈旧 idle 不采信」三条分支 |
+
+### 修复后的判定顺序(左侧)
+
+```
+① 当前会话 + 已收到 ✅              → 不转
+② 容器说本会话 running              → 转
+③ 容器说本会话 idle,但记录早于本地发出 → 不采信,继续 ↓
+④ 最近 QUIET_MS 内有本会话的 agent 消息 → 转(「在输出就转」)
+⑤ 本地「刚发出」标记(60 秒 TTL)    → 转(首字未到时的过渡)
+⑥ 以上皆否                          → 不转(「没回复就停」)
+```
+
+---
+
+## 四、验证证据
+
+| 项 | 命令 | 结果 |
+|---|---|---|
+| 类型检查 | `npx tsc -p tsconfig.app.json --noEmit` | ✅ 通过 |
+| 完整构建(含模板编译) | `npx ng build --configuration development` | ✅ `Application bundle generation complete` |
+| 单元测试 | `npx ng test --watch=false --browsers=ChromeHeadless` | ✅ **1686 SUCCESS**(原 1683 + 新增 3) |
+| lj 同事实测 | 以同事 token 发消息 | ✅ `👀 → 正文 → ✅`,含 HTML 交付 |
+| lj-test 对照组 | 以主人 token 发消息 | ✅ `👀 → 正文 → ✅` |
+
+---
+
+## 五、踩坑记录(给后来者)
+
+1. **dev server 编译失败时浏览器仍跑旧构建**。我在一轮排查里连续做了 6 次「看起来正常」的采样,
+   实际全部跑的是旧包 —— 因为终端日志里有 `X [ERROR]` 但被后续输出淹没。
+   **教训:动手改完先看构建输出,再谈验证。**
+2. **`ng serve` 端口不释放**:`TaskStop` 之后进程仍在监听 4200,新实例报
+   `Port 4200 is already in use`。需要 `netstat -ano | grep :4200` + `taskkill //PID <pid> //F`。
+3. **`tsc --noEmit` 不等于 Angular 编译通过**:模板里访问 `private` 成员,
+   `tsc` 不报,`ng build` 报 `TS2341`。**改完模板必须跑 `ng build`**。
+4. **不能从 mxid 推导用户的 sessionToken**(见 §1.3)。
+
+---
+
+## 六、仍未解决
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | 容器侧 `turnStartAt`→`updatedAt` 只差 1 秒 | 说明容器把「收尾时刻」写进了这两处,`turnStartAt` 语义失真。App 侧已兜底,根治需容器改 `status-report.py` |
+| 2 | lj 的 `heartbeatAt` 会随容器停止而停 | 停止时仍显示 `running`(幽灵态)。建议容器停机前主动报 `status: "offline"` |
+| 3 | 左侧转圈的浏览器级端到端验证 | 本轮受 §5.1 影响未能取得干净的浏览器采样;**逻辑已由 3 条单测固定**,仍建议部署后人工看一眼 |
+
+*本文所有时间线与数据均为 2026-11-12 的实测回读,非推断。*

+ 146 - 0
docs/00-tracking/2026-11-12-一房间一会话实施记录.md

@@ -0,0 +1,146 @@
+# 一房间一会话实施方案 · 执行记录(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 用户要求 | ① 实测建房耗时 ② 建房完成后再进页面 ③ 完整规划 App + 容器 |
+| 已实施 | 阶段 1(建房等待)、阶段 2(sync 提速) |
+| 待实施 | 阶段 4(容器任务书)、阶段 5(房间生命周期) |
+| 验证 | `tsc` ✅ / `ng build` ✅ / `ng test` **1706 SUCCESS** / 浏览器端到端 ✅ |
+
+---
+
+## 一、实测数据(决定方案的关键依据)
+
+### 1.1 建房耗时
+
+| 次数 | createRoom | 容器进房 | 总计 |
+|---|---|---|---|
+| 1 | 171ms | 717ms | **717ms** |
+| 2 | 83ms | 631ms | **631ms** |
+| 3 | 48ms | 598ms | **598ms** |
+
+**结论:0.6~0.7 秒**,用按钮 loading 覆盖,体验可接受。
+
+### 1.2 `/sync` 性能
+
+| 配置 | 耗时 | 事件数 |
+|---|---|---|
+| 无 filter(现状) | 1593~2225ms | 4010 |
+| `initialSyncLimit=50` | 2102ms | 5147 |
+| **`initialSyncLimit=5`** | **451ms** | **920** |
+
+**结论:降到 5 提速 3.5 倍**,历史由 `backfillSessionTimeline()` 回溯补齐。
+
+### 1.3 存量成果键
+
+```
+threadRoot = $approot_mwcbly72_rijso259:fmode.cn
+用房间号查 → 0 条
+```
+
+**结论:Thread ID 不能删**,否则 51 条会话的历史成果全部失联。
+
+---
+
+## 二、已实施改动
+
+### 阶段 1 · 建房后再进页面
+
+**问题**:`doStartBlank` 里 fire-and-forget 建房,用户秒发消息时房间还没建好 →
+消息落进共享房间。实测 5 条测试会话全部还在 `!miyB1Dx…`。
+
+**改动**:
+
+| 文件 | 改动 |
+|---|---|
+| `core/chat.service.ts` | 新增 `startBlankWithRoom()`:建会话 + **同步等建房**再返回;移除 `_sessionRoomInFlight`(不再需要) |
+| `components/domain/new-session-modal/new-session-modal.ts` | `createIn()` 改 async:建房 → 跳转到该会话页;加 `preparing` loading 态与「正在准备会话…」提示;按钮 `[disabled]` 防重复点击 |
+| `desktop/pages/chat/chat.ts` | `/chat/:id/new` 分支改用 `startBlankWithRoom` |
+| `mobile/pages/chat/chat.ts` | 同上 |
+
+**降级**:建房失败静默退回共享房间,**不阻塞**新建对话。
+
+### 阶段 2 · sync 提速
+
+| 文件 | 改动 |
+|---|---|
+| `core/matrix-client.service.ts` | 新增常量 `INITIAL_SYNC_LIMIT = 5`(带实测对比注释),两处 `startClient` 改用该常量 |
+
+---
+
+## 三、验证结果
+
+| 项 | 结果 |
+|---|---|
+| `npx tsc -p tsconfig.app.json --noEmit` | ✅ |
+| `npx ng build --configuration development` | ✅ complete,0 error |
+| `npx ng test --watch=false --browsers=ChromeHeadless` | ✅ **1706 SUCCESS** |
+| 首屏出会话 | **2085ms**(改造前约 5657ms) |
+| 新建会话 `createRoom` | ✅ **1 次**,会话拿到专属房间 `!Ag_cZHVp…`(不再是 `!miyB1Dx…`) |
+| `pageerror` | ✅ 无 |
+
+---
+
+## 四、发现的阻塞问题(需容器侧解决)
+
+### 4.1 容器状态上报严重滞后
+
+**实测**:连续观察 120 秒,`xlosi3m46q` 里该容器的 `updatedAt` **变化 0 次**。
+
+```
++0s  status=idle | idle记录 age=71s
++25s status=idle | idle记录 age=99s   ← 记录没有刷新
+...
+120 秒内 updatedAt 变化次数: 0
+```
+
+**后果**:App 即使每 15 秒轮询,拿到的也是旧数据 → 只能等本地「刚发出」标记的 **60 秒 TTL** 兜底
+→ 实测**转圈 71 秒才停**。
+
+### 4.2 App 侧逻辑已验证正确
+
+用纯函数模拟四种情形,判定全部符合预期:
+
+| 情形 | 判定 |
+|---|---|
+| 发送后容器还没报 | `undefined` → 回落本地标记(转) |
+| 容器报 `running` | `true` → 转 |
+| 容器报 `idle`(本轮) | `false` → **立刻不转** |
+| 容器报 `idle`(上一轮陈旧) | `undefined` → 回落本地标记 |
+
+**即:只要容器及时上报,App 就能立刻停。慢的原因在容器侧。**
+
+---
+
+## 五、剩余待实施
+
+### 阶段 4 · 容器任务书
+
+| # | 要求 | 理由 |
+|---|---|---|
+| 1 | **`currentSessions` 变化时立即上报**,不等下一个心跳周期 | 解决 4.1 的 71 秒延迟 |
+| 2 | `idle` 记录超过 5 分钟从 `currentSessions` 移除 | 当前保留 13 小时,干扰匹配 |
+| 3 | `_thread_root()` 取不到 thread 时退化为 `chat_id`(房间号) | 一房间一会话下房间号即会话标识,避免成果登记被跳过 |
+| 4 | `sessionKey` 有 thread 报 thread,没有报 roomId,**绝不省略** | 保证 App 总能匹配 |
+
+### 阶段 5 · 房间生命周期
+
+| 措施 | 说明 |
+|---|---|
+| 建房标记 | ✅ 已加 `creation_content: {'fmode.session_room': true}` |
+| 统计脚本 | `scripts/` 加房间统计,超阈值告警 |
+| 归档策略 | 长期不活跃会话房间列入归档(Matrix 无删除语义,只标记) |
+
+---
+
+## 六、结论
+
+| 问题 | 结论 |
+|---|---|
+| 一房间一会话是否可行 | ✅ 可行,容器零改造,已实测 |
+| 建房耗时 | ✅ 0.6~0.7 秒,用户可接受 |
+| 是否需要 Room + Thread 并存 | ✅ **需要**(存量成果绑 thread,容器报 thread) |
+| App 端是否完成 | ✅ 阶段 1、2 已完成并验证 |
+| 是否还有问题 | ⚠️ **容器上报滞后 120 秒**,需容器侧任务书 |
+
+*本文所有数据为 2026-11-12 实测,非推断。*

+ 208 - 0
docs/00-tracking/2026-11-12-会话隔离架构分析与方案.md

@@ -0,0 +1,208 @@
+# 会话隔离架构:一房间一会话 vs 现状(2026-11-12 实测)
+
+| 字段 | 值 |
+|---|---|
+| 问题 | 一个会话能否对应一个房间?相比现状有何优劣?最佳实践是什么? |
+| 结论 | **技术上完全可行,且这是容器原生支持的方式**;当前「一容器一房间挂 N 会话」是导致乱转的根源 |
+| 依据 | 本日**真实接口实测**(新房间创建 → 容器进房 → 并发回复 → 状态上报) |
+
+---
+
+## 一、直接回答:能否实现「一会话一房间」
+
+### 1.1 实测证据(2026-11-12,全部真实调用,非推断)
+
+**测试 1 · 客户端自建房间能否与容器对话**
+
+```
+POST /createRoom {invite:[@zgdcmgnqcd:fmode.cn], is_direct:true, name:"房间隔离测试"}
+→ 200,房间 !rcrlumOyYhCT6qevGVzLg54OJN1s6MxvIDKP-2uO2yk
+→ 容器 3 秒内**自动进房**(无需任何配置)
+→ 发「请只回复两个字:收到」→ 容器回复「收到」
+```
+
+**测试 2 · 同一容器能否同时在多个房间独立工作**
+
+```
+新建两个房间 A / B,同时各发一个问题
+→ 容器 status = running
+→ currentSessions 出现 **2 条独立的 running 记录**:
+     !5jJdsHRObQz_zHR9gXxbCzI1g
+     !qJTfcxOgHtoKs5q-6tHBBY0Xg
+→ 房间A 回复「房间A的答案」,房间B 回复「房间B的答案」
+```
+
+**结论:完全可以。** 而且容器不需要任何改造 —— 它本身就按房间监听、按房间维护会话状态。
+
+### 1.2 为什么现在不是一房间一会话
+
+现状的结构是:
+
+```
+用户 ──── DM 房间(provision 创建,1 个)──── 容器
+              └── 51 条会话(thread)全挂在这一个房间里
+```
+
+原因在 `fmodeagent-matrix-provision.js`:**provision 为「用户 × 容器」只建一个房间**,并写进绑定表 `FmodeMatrixBinding.roomId`;App 打开会话时才在这个房间内建 thread。
+
+实测:`agent-node-lj-test` **51 条会话 / 1 个房间**;`agent-lj` **2 条会话 / 1 个房间**。
+
+---
+
+## 二、两种架构对比
+
+### 2.1 结构图
+
+**现状:一容器一房间 + thread 区分的 N 会话**
+
+```
+        ┌──────────────── 房间 !miyB1Dx... ────────────────┐
+用户 ───│ thread A │ thread B │ thread C │ ... 共 51 个     │──── 容器
+        └──────────────────────────────────────────────────┘
+                容器上报 sessionKey = !miyB1Dx...(房间号)
+                ↓
+        App 无法区分是哪条会话 → 全房间一起转
+```
+
+**方案:一会话一房间**
+
+```
+        ┌─ 房间 R1 ─┐
+用户 ───┼─ 房间 R2 ─┼──── 容器(一个 sync 监听全部房间)
+        └─ 房间 R3 ─┘
+                容器上报 sessionKey = !R1 / !R2 / !R3
+                ↓
+        一条记录精确对应一个会话 → 天然隔离
+```
+
+### 2.2 逐项对比
+
+| 维度 | 现状(1 房间 N 会话) | 一会话一房间 | 优势方 |
+|---|---|---|---|
+| **状态隔离** | ❌ 房间号无法区分会话,状态互相传染 | ✅ 一个房间 = 一个会话,记录天然隔离 | **新方案** |
+| **容器改造量** | — | **零**(实测容器自动进房、按房间维护状态) | **新方案** |
+| **App 改动量** | — | 中:房间生命周期、会话↔房间映射需重做 | 现状 |
+| **历史数据兼容** | — | 51 条老会话仍在老房间,需迁移或降级展示 | 现状 |
+| **provision 依赖** | 强依赖(房间由云函数建) | **弱**(客户端可自建) | **新方案** |
+| **Matrix 资源占用** | 低(1 房间) | 高(N 房间,每个房间都占 sync 数据) | 现状 |
+| **首屏同步量** | 173 房间 / 5013 事件(已偏慢) | **会进一步增加**,需配合清理 | 现状 |
+| **thread 机制依赖** | 强(多会话靠 thread 区分) | **弱**(不再需要 thread) | **新方案** |
+| **多人共用容器** | 每人一房间(已如此) | 每人 N 房间,仍隔离 | 持平 |
+| **`thread_id` 覆盖率问题** | 依赖 54% 的覆盖率 | 不再依赖 | **新方案** |
+
+### 2.3 一句话结论
+
+> 现状的问题不是「thread 用错了」,而是**把 1:N 的关系硬编码进了状态上报**。
+> 容器上报 `!roomId` 是**诚实**的(它只知道房间),错的是 App 把它当成会话 id。
+
+---
+
+## 三、当前逻辑的问题清单(异常点)
+
+### 3.1 状态链路
+
+| # | 异常 | 触发条件 | 用户可见表现 |
+|---|---|---|---|
+| **E1** | 房间号退路让同房间**所有会话**匹配同一个 running | 容器上报 `!roomId` 且状态为 running | 一条在跑,51 条全转 |
+| **E2** | `✅` 被当成回合结束 | 长任务分多条发送,每条各一个 ✅ | 第一条 ✅ 到达就显示"完成",正文还在输出 |
+| **E3** | 本地「刚发出」标记只在收到 ✅ 时清除 | 容器没回复(准入拦截 / 重启 / 丢消息) | 空转满 60 秒 |
+| **E4** | 陈旧 `idle` 记录不失效 | 容器长期保留历史记录(实测最久 13.4 小时) | 当前无害(都是 idle),但一旦有陈旧 running 就长期误转 |
+| **E5** | 容器级 `status` 与 `currentSessions` 口径不一致 | agent-lj:`status=running` 但 19 条记录全是 `idle` | 工作台与左侧显示矛盾 |
+| **E6** | `sessionKey` 格式不统一 | 同一容器同时上报 `!roomId` 与 `$approot_` | App 无法用统一规则匹配 |
+
+### 3.2 数据与架构
+
+| # | 问题 | 影响 |
+|---|---|---|
+| **E7** | 会话与房间是 N:1,但状态上报按房间 | 根本矛盾,见 E1 |
+| **E8** | 依赖 `thread_id`,实测覆盖率约 54% | 一半会话无法精确匹配 |
+| **E9** | 51 条会话全在一个房间 → 单房间时间线巨大(2482 事件) | 首屏需回溯、加载慢 |
+| **E10** | 账号下累计 173 个 Matrix 房间 | `/sync` 首包 ~1s,拉 5013 事件 |
+
+---
+
+## 四、最佳实践建议
+
+### 4.1 推荐:**一房间一会话**(长期)
+
+**理由**:
+1. 容器**原生支持**(实测零改造);
+2. 状态**天然隔离**,不需要 App 做任何"猜"的匹配;
+3. 摆脱 `thread_id` 覆盖率问题(E8);
+4. 单房间时间线小,加载更快(E9 缓解)。
+
+**需要配套做的事**:
+
+| # | 事项 | 说明 |
+|---|---|---|
+| 1 | 会话 ↔ 房间映射持久化 | 新建会话 = 新建房间,映射写入 `ChatThread.roomId` |
+| 2 | 老会话兼容 | 51 条老会话仍在老房间,**保留只读展示**,不迁移(迁移风险高) |
+| 3 | 房间清理 | 定期归档长期不活跃的房间,控制 sync 规模(E10) |
+| 4 | provision 改造 | 可选:不再强依赖 provision 建房,客户端自建(实测可行) |
+
+### 4.2 过渡期:先止血(短期)
+
+在架构改造完成前,**必须先消除 E1~E3 造成的乱象**:
+
+| 动作 | 解决 |
+|---|---|
+| 删除 `roomId` 匹配退路 | E1(宁可少转,不能乱转) |
+| `✅` 退出左侧判断,只认 `currentSessions.status` | E2 |
+| 发送后立即转(本地),收到该会话 `idle` 才停 | E3 |
+| 陈旧记录按 `updatedAt` 统一过期(含 idle) | E4 |
+
+### 4.3 容器侧配合(无论选哪个方案都要做)
+
+| # | 要求 | 理由 |
+|---|---|---|
+| 1 | `sessionKey` **只报确定性标识**,绝不退化成房间号凑数 | E6 |
+| 2 | 容器级 `status` 与 `currentSessions` 口径一致 | E5 |
+| 3 | `idle` 记录超时清理(建议 5 分钟) | E4 |
+| 4 | 提供**明确的回合结束信号**(现状 `idle` 可用,但需保证可靠) | E2 |
+
+> 若采用「一房间一会话」,第 1 条自然满足(房间号 = 会话 id,无歧义),
+> 这也是该方案最大的吸引力。
+
+---
+
+## 五、方案选择建议
+
+| 方案 | 状态隔离 | 容器改造 | App 改造 | 周期 | 适用 |
+|---|---|---|---|---|---|
+| **A. 止血**(删退路 + ✅ 退出左侧) | 部分 | 无 | 小 | 半天 | 立即执行 |
+| **B. 一房间一会话** | 完全 | 无 | 中 | 2~3 天 | **长期目标** |
+| **C. 容器补正确 sessionKey** | 完全 | 需重启 | 无 | 1 天 | 与 A 并行 |
+
+**推荐路径**:**A(止血)→ C(容器补正确标识)→ B(架构升级)**
+
+- A 先让用户不再看到乱转;
+- C 让现有架构下的转圈恢复正确;
+- B 从根本上消除 thread 依赖与匹配歧义。
+
+---
+
+## 六、本次实测的副作用(需知悉)
+
+测试过程中创建了 3 个临时房间:
+
+| 房间 | 用途 |
+|---|---|
+| `!rcrlumOyYhCT6qevGVzLg54OJN1s6MxvIDKP-2uO2yk` | 单房间对话测试 |
+| `!qJTfcxOgHtoKs5q-6tHBBY0Xg...` | 并发测试 A |
+| `!5jJdsHRObQz_zHR9gXxbCzI1g...` | 并发测试 B |
+
+它们**不在会话目录里**(未写 `ChatThread`),App 界面上不会出现,但会占用容器和账号的房间数。
+后续清理时按 roomId 归档即可。
+
+---
+
+## 附:证据索引
+
+| 证据 | 复现方式 |
+|---|---|
+| 容器自动进房 | `POST /createRoom {invite:[agentMxId]}` 后轮询 `joined_members` |
+| 并发独立状态 | 两个房间同时发消息 → 回读 `extra.currentSessions` 出现 2 条 running |
+| 会话/房间是 N:1 | `oR5O67i1Cm op:list` 按 roomId 分组 |
+| 容器结束信号语义 | 向 `agent-node-lj-test` 实测问答 |
+
+*本文所有数据为 2026-11-12 实测,非推断。*

+ 153 - 0
docs/00-tracking/2026-11-12-历史会话打不开-根因与修复.md

@@ -0,0 +1,153 @@
+# 历史会话打不开:根因与修复(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 现象 | 打开 lj-test 的历史会话,显示「这个会话没有可加载的消息」/欢迎页 |
+| 结论 | **数据没丢**。50/51 条会话内容完好;修复后已能正常查看(用户实测确认) |
+| 验证 | `tsc` ✅ / `ng build` ✅ / `ng test` **1695 SUCCESS** |
+
+---
+
+## 一、根因(三层叠加)
+
+### R1 · sync 只保留最近 50 条,且从不向前翻 ★主因
+
+`matrix-client.service.ts:503/911`:
+
+```ts
+this.client.startClient({ initialSyncLimit: 50 });
+```
+
+只把房间**最近 50 个事件**放进本地 timeline。而 `timelineToMessages()`
+只读**本地** timeline,**没有任何主动向前分页的代码**。
+
+实测数据:
+
+| 项 | 值 |
+|---|---|
+| lj-test 房间事件 | 2482 个(还在增长) |
+| 会话目录 | 51 条 |
+| 目标会话最新消息在房间中的排位 | 第 52、68、… 位 |
+| 结论 | 老会话的消息**根本不在本地** → 打开即空白 |
+
+### R2 · 空态判据恒为真,伪装成「全新会话」
+
+`chat.service.ts` 原判据 `hadAnyEvent = rawTimeline.length > 0` ——
+本地那 50 条**属于最新会话**,因此恒为真,等于没有判据。
+于是「有历史但取不到」也被渲染成欢迎页,用户以为记录丢了。
+
+### R3 · 空壳会话被说成「暂时取不到」
+
+「测试9680」在 Matrix 里**一条消息都没有**(全房间扫描确认),
+但云端目录有记录、`messageCount=1`。文案说「暂时取不到」是误导 ——
+它不是「暂时」,是真没有。
+
+---
+
+## 二、修复
+
+### 2.1 按会话回溯(`ChatService.backfillSessionTimeline`)
+
+位置:`core/chat.service.ts`,在 `openReal()` 中 `sid` 确定之后、读取 timeline 之前调用。
+
+```
+本地匹配不到该会话
+   ↓
+await mx.synced()            ← 必须等初次 sync 落地
+   ↓
+循环:paginateBack(rid, 100) ← 复用已有分页,命中即停
+   ↓
+命中 → 正常渲染;翻到底仍无 → 空态判定
+```
+
+**关键实现点(都是踩坑换来的)**:
+
+| # | 坑 | 处理 |
+|---|---|---|
+| 1 | `paginationToken()` 在 store 未就绪时返回 `null`,被误判「已到顶」→ **一次请求都不发** | 先 `await mx.synced(15_000)` |
+| 2 | 回溯后没重读 `rawTimeline`,补回的数据不被采用 | 回溯后**重新赋值** |
+| 3 | 与 `paginate()` 并发翻同一条 timeline,token 互相覆盖 | 共用 `_paginating` 锁 |
+| 4 | `paginateEventTimeline` 返回 `false` = **到尽头**,不是出错(`client.ts:5054`) | 据此置 `_reachedRoomStart` |
+| 5 | 大房间翻满轮数上限时 token 仍在,不能直接判定「没翻完」 | 用 `_reachedRoomStart` 显式记录 |
+
+**性能**:有缓存时 0.5~0.7 秒;多数会话 1~2 轮即命中。
+
+### 2.2 空态三态分明
+
+`_emptyReason` 从 `'fresh' | 'unloaded' | 'removed'` 扩展为
+`'fresh' | 'unloaded' | 'blank' | 'removed'`:
+
+| 情形 | 判定 | 界面 |
+|---|---|---|
+| 有消息 | `null` | 正常渲染 |
+| 找到事件但被归属规则挡掉 | `unloaded` | 「会话记录还在,但消息暂时取不到」 |
+| **翻遍房间 + 目录有记录 + 0 条消息** | **`blank`** | **「这条对话没有保存任何消息」+ 新建对话** |
+| 还没翻到底 | `unloaded` | 同上(诚实说未加载完) |
+| 真新会话 | `fresh` | 欢迎页 |
+
+桌面 `desktop/pages/chat/chat.ts` 与移动 `mobile/pages/chat/chat.ts` 均已加 `blank` 分支。
+
+### 2.3 连接断开自愈
+
+**根因**:`matrix-js-sdk` 的 `SyncApi.shouldAbortSync()` 遇到 `M_UNKNOWN_TOKEN`
+会直接 `this.stop()`(`sync.ts:586-595`)—— **永久停止 sync**,
+之后网络恢复也不会重连,只能手点「重新连接」。
+
+**修复**:`MatrixClientService` 监听 Sync 事件时,用 `isUnknownToken()` 判定,
+命中则调 **已有的** `FmodeApi.validateMatrix()`(云函数 `validate:1`)重签凭据并重建连接。
+指数退避(0s / 3s / 9s),最多 3 次,失败仍保留手动按钮。
+
+---
+
+## 三、验证
+
+| 项 | 结果 |
+|---|---|
+| `npx tsc -p tsconfig.app.json --noEmit` | ✅ |
+| `npx ng build --configuration development` | ✅ complete |
+| `npx ng test --watch=false --browsers=ChromeHeadless` | ✅ **1695 SUCCESS**(新增 4 条) |
+| 历史会话(Playwright,5 个抽样) | ✅ 4 个有内容的全部显示正文 |
+| 空壳会话 | ✅ 显示「这条对话没有保存任何消息」 |
+| 用户实测 | ✅ 「当前已经能够看到历史数据了」 |
+
+新增测试:
+- `chat.service.spec.ts`:回溯命中 / 命中即停 / 无 token 不回溯 / 翻到底 → `blank` / 未翻到底 → `unloaded`
+- `matrix-client.service.spec.ts`:`isUnknownToken()` 4 条
+
+---
+
+## 四、数据盘点(供后续核对)
+
+| 对比项 | 数量 |
+|---|---|
+| 会话目录(`oR5O67i1Cm`) | 51 |
+| Matrix 实际线程根 | 104 |
+| **两边都有(可正常打开)** | **50** |
+| 只在目录、Matrix 无消息(空壳) | 1(「测试9680」) |
+| 只在 Matrix、目录无记录 | 54(旧数据,本就无入口) |
+
+---
+
+## 五、未完成 / 待观察
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | 冷启动(无 SDK 缓存)下的深度回溯 | 实测冷启动约 26 轮请求后停止,未能翻到房间最早,会显示 `unloaded` 而非 `blank`。**不影响有内容的会话**(它们命中即停),只影响「空壳 + 冷启动」这一组合。下次可考虑:回溯预算按会话在目录中的位置动态调整,或直接在服务端按 `threadRoot` 查询。 |
+| 2 | 「测试9680」软删 | 用户已裁决软删,**本轮未执行**(涉及写云端数据,需单独确认后操作) |
+| 3 | 账号密码登录 / 首页默认新建 / 新人导引网页 | 已排入后续,本轮未做 |
+
+---
+
+## 六、踩坑记录
+
+1. **`ng serve` 编译失败时浏览器仍跑旧包**:一轮排查里 6 次「看起来正常」的采样全部无效。
+   **改完必须先看构建输出再谈验证。**
+2. **`tsc --noEmit` 测不出 Angular 模板错误**(模板访问 `private` 成员),必须跑 `ng build`。
+3. **`paginateEventTimeline` 的 `false` 是「到尽头」不是「出错」**,读 SDK 源码确认。
+4. **`paginationToken()` 的 `null` 有歧义**:既可能是「到顶」,也可能是「store 还没就绪」。
+5. **`rm .tmp-*.mjs` 误删了仓库里被跟踪的 4 个调试脚本**,已 `git checkout` 恢复。
+   以后清理临时文件要按精确文件名,不要用通配符。
+
+---
+
+*本文数据均为 2026-11-12 实测(Matrix 全房间扫描 / 云函数回读 / 浏览器采样),非推断。*

+ 154 - 0
docs/00-tracking/2026-11-12-左侧转圈修复与一房间一会话.md

@@ -0,0 +1,154 @@
+# 左侧转圈修复 + 一房间一会话(实施记录,2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 用户反馈 | ① 切走后原会话开始转 ② 没回复也在转 ③ 效果糟糕、没有隔离 |
+| 用户要求 | 「发送之后就直接开始加载;**结束按容器上报**」;会话之间必须彻底隔离 |
+| 实施 | App 端状态判定重写 + 新建会话独立房间 |
+| 验证 | `tsc` ✅ / `ng build` ✅ / `ng test` **1706 SUCCESS** / 浏览器端到端 ✅ |
+
+---
+
+## 一、改动前的问题(与用户现象一一对应)
+
+| 现象 | 根因 |
+|---|---|
+| **没回复也在转** | 容器上报 `sessionKey = !roomId`,App 用 `includes(roomId)` 匹配 → 同房间 **51 条会话全部命中** |
+| **切走后开始转** | 本地「刚发出」标记只在收到 `✅` 时清除;没回复就没有 ✅ → 空转满 60 秒 |
+| **回复完了还转** | `✅` 被当成回合结束,但容器明确说它只代表「这条消息发完了」 |
+| **陈旧状态干扰** | 容器长期保留记录(实测最久 13.4 小时),`idle` 不做过期判断 |
+
+---
+
+## 二、实施的改动
+
+### 2.1 房间号匹配:从子串改为精确
+
+`core/agent-state.ts::sessionStateOf()`
+
+```diff
+- return pool.find((s) => s.sessionKey.includes(roomIdOf));   // 子串 → 同房间全部命中
++ const exact = pool.find((s) => {
++   const key = s.sessionKey;
++   if (key === roomIdOf) return true;                     // 纯房间号(一房间一会话)
++   const last = key.split(':').pop() ?? '';
++   return last === roomIdOf && last.startsWith('!');      // agent:main:...:!roomA
++ });
+```
+
+**为什么不用 `split(/[:|,]/).includes(...)`**:实测验证过,`!roomA:other_thread`
+拆开后仍含 `!roomA`,**传染问题依旧**。只有「整体判等」或「最后一段判等」才安全。
+
+已用 5 个边界用例验证:
+
+| sessionKey | roomId | 结果 |
+|---|---|---|
+| `!roomA` | `!roomA` | ✅ 命中 |
+| `agent:main:matrix:dm:!roomA` | `!roomA` | ✅ 命中 |
+| `!roomA:other_thread` | `!roomA` | ❌ **不命中**(关键) |
+| `!roomAxxx` | `!roomA` | ❌ 不命中 |
+| `!roomA:!roomB` | `!roomA` | ❌ 不命中 |
+
+### 2.2 左侧判定重写
+
+`components/domain/session-tree/session-tree.ts::sessionRunningOf()`
+
+新的判定顺序(**✅ 已完全退出左侧**):
+
+```
+① 容器说正在跑(sessionRunning === true)      → 转
+② 容器说已结束(新鲜 idle,且晚于本地发出)    → 不转
+③ 本会话最近 25 秒内有 agent 消息(真实输出)  → 转
+④ 本地「刚发出」标记(TTL 60 秒,过渡态)      → 转
+⑤ 其余                                        → 不转
+```
+
+同时修了一个实际 bug:模板原先调 3 次 `sessionRunningOf()`,
+时间相关的判定在两次调用间可能落到不同分支 → class 与 title 打架
+(实测抓到「title=正在执行 但没有 spinning 类」)。改为 `@let spinning = ...` 只求值一次。
+
+### 2.3 一房间一会话
+
+`core/matrix-client.service.ts` 新增:
+
+| 方法 | 作用 |
+|---|---|
+| `createSessionRoom(agentMxId)` | 为会话建独立房间,带 `creation_content: {'fmode.session_room': true}` 便于日后清理 |
+| `waitAgentJoined(roomId, agentMxId, timeout)` | 等容器进房(实测约 3 秒) |
+
+`core/chat.service.ts` 新增 `provisionSessionRoom()`:
+- 新建会话后**后台**建房 + 等容器进房,不阻塞用户打字;
+- 成功后回写 `sessions.setRoomId()`,并同步切换当前房间指针;
+- **任一步失败静默降级**回绑定房间(老行为)——新建对话绝不能因建房失败而失败。
+
+`openReal()` 增加:会话已有专属房间时优先进入**那个**房间,否则消息会发进老房间、状态又混在一起。
+
+`core/chat-session.service.ts` 新增 `setRoomId()`:与 `moveTo()` 同一纪律,未落库的会话只改本地,随首次发消息一起落库。
+
+---
+
+## 三、多房间的影响评估(用户重点关注)
+
+### 3.1 文件 / 成果逻辑:**不受影响**
+
+| 事实 | 证据 |
+|---|---|
+| 文件台账按 **`threadRoot`** 归属 | `cloud/functions/fmode-file-hub.js:301` `if (!threadRoot) return _fail(...)` |
+| `roomId` 只是附带字段 | 同上 L39 字段清单 |
+| 客户端成果服务不读 roomId | `core/deliverables.service.ts` 无 roomId 引用 |
+
+**结论:一房间一会话不会改变成果归属**,成果仍按会话(threadRoot)隔离。
+
+### 3.2 其它面的影响
+
+| 方面 | 影响 | 处理 |
+|---|---|---|
+| Matrix 房间数增加 | `/sync` 首包变大(现状 173 房间已约 1 秒) | 房间带 `fmode.session_room` 标记,便于后续批量清理 |
+| 老会话(51 条) | 仍在老房间,**不迁移**,按原逻辑只读展示 | 老房间内匹配已改为精确判等,不会再互相传染 |
+| 群聊 | 不受影响(群本身就是一个房间) | — |
+| 未读计数 | 按 roomId 归属 agent,多房间已支持 | 实测无异常 |
+| 历史回溯 | `backfillSessionTimeline` 按房间进行,独立房间反而更快 | — |
+
+### 3.3 风险与兜底
+
+| 风险 | 兜底 |
+|---|---|
+| 建房失败(离线/限流) | 静默降级回绑定房间,会话照常可用 |
+| 容器 30 秒内未进房 | 放弃该房间,退回绑定房间 |
+| 建房后消息发错房间 | `openReal()` 优先使用会话自己的 roomId;`provisionSessionRoom` 同时切换房间指针 |
+
+---
+
+## 四、验证结果
+
+| 项 | 命令 | 结果 |
+|---|---|---|
+| 类型 | `npx tsc -p tsconfig.app.json --noEmit` | ✅ |
+| 构建 | `npx ng build --configuration development` | ✅ complete |
+| 单测 | `npx ng test --watch=false --browsers=ChromeHeadless` | ✅ **1706 SUCCESS**(新增 5 条) |
+| UI 规范 | `npm run verify:ui` | 10/12(2 项为**改动前已存在**:AC-12.1 误报 6 处、AC-12.6 统计口径) |
+
+**浏览器实测(Playwright,真实后端)**:
+
+| 场景 | 结果 |
+|---|---|
+| **异常② 静置 15 秒不发消息** | ✅ 无凭空转圈 |
+| **异常① 发送后切走** | ✅ 只有发送的那条在转,切走后不扩散 |
+| 新建会话发首条消息 | ✅ 触发 1 次 `createRoom`,会话获得独立房间 |
+| `pageerror` | ✅ 无 |
+
+---
+
+## 五、遗留
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | 老会话仍在共享房间 | 51 条不迁移;靠精确判等避免传染,但容器若只报房间号,这些会话**不会显示转圈** |
+| 2 | 房间清理 | 每个新会话一个房间,需定期归档不活跃房间,否则 sync 变慢 |
+| 3 | 容器侧 `sessionKey` | 仍建议改为只报确定性 id(任务书另出) |
+| 4 | 账号密码登录 | 已在 `2026-11-12` 实现,移动端登录未最终验证 |
+| 5 | Token 直登入口 | 建议移除(普通用户无意义) |
+
+---
+
+*本文所有数据为 2026-11-12 实测,非推断。*

+ 223 - 0
docs/00-tracking/2026-11-12-左侧转圈现状分析与方案.md

@@ -0,0 +1,223 @@
+# 左侧会话转圈:现状分析与方案对比(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 现象 | ① 切到别的会话后,原会话开始转 ② 明明没回复、没跑任务,历史会话也一直转 |
+| 用户原话 | 「这个效果非常糟糕,没有进行同步」「所有会话当前的状态是需要分隔隔离的」 |
+| 本文性质 | **只做分析与方案对比,不改任何代码** |
+| 依据 | 容器实测回答 + 云函数实时回读 + 代码逐行核对 |
+
+---
+
+## 一、数据源:容器实际上报了什么
+
+### 1.1 实测快照(2026-11-12,云函数 `xlosi3m46q` 回读)
+
+**`agent-node-lj-test`**:`status=idle`,`currentSessions` 共 **1 条**
+
+| sessionKey | status | 距今 |
+|---|---|---|
+| `!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU` | idle | 568s |
+
+**`agent-lj`**:`status=running`,`currentSessions` 共 **19 条**
+
+| 类型 | 条数 | 说明 |
+|---|---|---|
+| `!roomId`(房间号) | **14** | 年龄 1.1 小时 ~ 13.4 小时 |
+| `$approot_xxx`(会话 id) | **4** | 年龄 2 小时 ~ 13.4 小时 |
+
+**关键观察**:
+- 两种 `sessionKey` 格式**同时存在**,说明容器侧并未统一;
+- 记录**长期保留不清理**(最早的已 13 小时);
+- 全部是 `idle`,但容器级 `status` 却报 `running` —— 两者口径不一致。
+
+### 1.2 容器自述的结束信号(实测问答)
+
+| 信号 | 容器原话 | 实际含义 |
+|---|---|---|
+| Matrix `✅` reaction | 「adapter 在 `send()` 完成后立即打标,表示**这条消息**已处理完,**不代表这一轮对话已结束**」 | 单条消息投递完成 |
+| `extra.currentSessions[].status` | 「`post_llm_call` 钩子里 `_session_mark_idle()`(`__init__.py:601-611`),本轮 finalize 时从 `running` 变 `idle`」 | **真正的回合结束** |
+
+**结论:`✅` 不是回合结束信号,`idle` 才是。当前 App 用错了。**
+
+### 1.3 `sessionKey` 的来源(文档层面)
+
+三份文档一致(`容器接入标准流程-SOP-v3:139`、`提示词-容器自我优化-v2:139`、`多用户共用容器-完整规范:351`):
+
+```
+sessionKey = thread_id($approot_xxx)优先 → 拿不到退 chat_id(!roomId)
+```
+
+---
+
+## 二、关键事实:房间号 ≠ 会话
+
+### 2.1 实测
+
+| 容器 | 会话数 | 不同房间数 |
+|---|---|---|
+| `agent-node-lj-test` | **51** | **1** |
+| `agent-lj` | **2** | **1** |
+
+### 2.2 架构说明
+
+Matrix 侧的结构是:
+
+```
+一个用户 × 一个容器 = 一个 DM 房间(1:1)
+        └── 房间内可挂 N 条会话(用 thread 区分)
+```
+
+所以:
+- **回答用户的问题**:新建会话 **不是** 新建房间。房间是「人 ↔ 容器」的通道,会话是通道内的 thread。
+- **推论**:`sessionKey = !roomId` 时,**无法区分是 51 条里的哪一条**。
+
+---
+
+## 三、App 端当前逻辑(逐行核对)
+
+### 3.1 左侧转圈判定 `session-tree.ts::sessionRunningOf()`
+
+按顺序短路,任一命中即返回:
+
+| 顺序 | 条件 | 代码位置 | 问题 |
+|---|---|---|---|
+| ① | 当前会话 + 已收到 ✅ | `sessionRunningOf` 开头 | **✅ 是错的信号**(见 §1.2) |
+| ② | 容器说本会话 `running` | `sessionRunning()` → true | 正确 |
+| ③ | 容器说本会话 `idle` | `sessionRunning()` → false | 正确 |
+| ④ | 最近 25 秒内有本会话 agent 消息 | `sessionIsWriting()` | 辅助,尚可 |
+| ⑤ | 本地「刚发出」标记(60 秒 TTL) | `sessionLocalStartAt()` | 只在收到 ✅ 时清除 → 没回复就空转满 60 秒 |
+
+### 3.2 会话匹配规则 `agent-state.ts::sessionStateOf()`
+
+```ts
+① 精确:s.sessionKey.includes(核心会话id)  → 命中
+② 退路:s.sessionKey.includes(roomId)     → 命中   ← ★ 问题所在
+```
+
+### 3.3 陈旧记录处理 `sessionRunning()`
+
+```ts
+running 记录:updatedAt 超过 10 分钟 → 视为过期(返回 undefined)
+idle  记录:**不做过期判断**,永远参与匹配
+```
+
+---
+
+## 四、根因(三个独立缺陷,共同造成「乱转」)
+
+### R1 · 房间号退路导致「全房间一起转」★最主要
+
+```
+lj-test:51 条会话全部共享 !miyB1Dx...
+容器上报一条 !miyB1Dx... 的 running
+        ↓
+App 退路 sessionKey.includes(roomId) 对 51 条会话**全部成立**
+        ↓
+51 条会话同时转圈
+```
+
+**这直接解释用户现象②**:明明这条会话没在跑,它也转 —— 因为是**别的会话**在跑。
+
+### R2 · ✅ 被误当成"回合结束"
+
+长任务时容器分多条发送,每条成功后各打一个 ✅。
+App 收到**第一条** ✅ 就把 `_turnEndedAfter` 置位 → 判定本轮结束。
+用户以为完成了,实际正文还在输出。
+
+### R3 · 本地「刚发出」标记只在收到 ✅ 时清除
+
+- 若容器不回复(准入拦截 / 网关重启 / 消息丢失)→ 永远等不到 ✅
+- 标记只能靠 60 秒 TTL 自然过期
+- **这解释用户现象①**:切走后原会话仍在转,因为标记还在
+
+### R4 · 陈旧 `idle` 记录不失效
+
+`idle` 不做过期判断 → 13 小时前的记录仍在参与匹配。
+当前它们恰好都是 `idle` 所以无害,但**只要有一条陈旧的 `running` 卡住,就会长期误转**。
+
+---
+
+## 五、是否符合需求
+
+| 用户需求 | 当前是否满足 | 原因 |
+|---|---|---|
+| 会话之间彻底隔离,不互相影响 | ❌ | R1:房间号退路让同房间会话互相传染 |
+| 「AI 在回复」才转 | ❌ | R3 + R1:没回复也可能转 |
+| 「回复结束」立即停 | ❌ | R2:✅ 不是结束信号;R3:没回复要空转 60 秒 |
+| 多人多会话同时使用互不干扰 | ❌ | R1 会让一个人的会话影响另一个人的 |
+
+**结论:当前设计不满足需求,且根因在「数据粒度 + 信号语义」两层,不是单个 bug。**
+
+---
+
+## 六、三个候选方案
+
+### 方案 A · 容器规范化 + App 精简(推荐)
+
+| 端 | 改动 |
+|---|---|
+| **容器** | ① `sessionKey` **只报 `$approot_xxx`**,拿不到就**不报这条**,禁止退化成 roomId<br>② 本轮 `post_llm_call` 时置 `idle`(已有)<br>③ **清理陈旧记录**:`idle` 超过 5 分钟从 `currentSessions` 移除<br>④ 容器级 `status` 与 `currentSessions` 口径对齐 |
+| **App** | ① 发送后**立即转**(本地即时反馈,不等容器)<br>② 只认 `sessionKey` **精确等于**本会话 id<br>③ **删除 roomId 退路**<br>④ **✅ 退出左侧判断**(仅保留给右侧回合卡片)<br>⑤ 收到该会话的 `idle` / 记录消失 → 停止 |
+
+**优点**:语义清晰、彻底隔离、符合用户需求。
+**代价**:容器改造需重启(打断进行中对话);改造完成前 lj-test 的 51 条会话**全都不转**(因为只报房间号)。
+
+### 方案 B · 只改 App
+
+保留 `$approot_` 精确匹配,删除房间号退路,✅ 退出左侧。
+
+**优点**:立即生效,风险最低。
+**代价**:lj-test 当前只报房间号 → **所有会话都不会转圈**,用户看不到「正在回复」。等于用「不转」换「不乱转」,体验降级。
+
+### 方案 C · 退路加限制
+
+房间号仍作退路,但要求「本会话确有未读 agent 消息」才采用。
+
+**优点**:改动最小。
+**代价**:治标不治本 —— 同一房间内多条会话都有未读时仍会一起转。
+
+---
+
+## 七、推荐组合
+
+**方案 A 的骨架 + 分两步落地**:
+
+```
+第 1 步(App 先行,立即消除乱转)
+  · 发送即转(本地,不依赖容器)
+  · 删除房间号退路
+  · ✅ 退出左侧判断
+  → 效果:再也��会「乱转」;但 lj-test 暂时不会转(因为容器只报房间号)
+
+第 2 步(容器改造,恢复正确的转圈)
+  · sessionKey 只报 $approot_xxx
+  · 清理陈旧 idle
+  → 效果:该转的转、该停的停,会话完全隔离
+```
+
+选择「App 先行」的理由:容器改造要重启、可能打断对话,而 App 端改动可以立即部署止血。改完后**宁可不转,也不能乱转**。
+
+---
+
+## 八、待确认事项
+
+1. 容器改造由谁执行?(我无法直接改容器,需按 SOP 出任务书交给容器侧)
+2. lj-test 的 51 条会话中,`thread_id` 有值的比例是多少?(决定改造后能否全部正确显示)
+3. 是否需要保留「Token 直登」入口?(账号密码登录上线后,该入口对普通用户已无意义)
+
+---
+
+## 附:证据索引
+
+| 证据 | 位置 |
+|---|---|
+| 会话与房间 1:N 实测 | 云函数 `oR5O67i1Cm` `op:list` 按 roomId 分组 |
+| `currentSessions` 实测 | 云函数 `xlosi3m46q` 回读 `extra` |
+| 容器结束信号自述 | 向 `agent-node-lj-test` 实测问答(2026-11-12) |
+| 左侧判定链路 | `components/domain/session-tree/session-tree.ts::sessionRunningOf()` |
+| 会话匹配规则 | `core/agent-state.ts::sessionStateOf()` / `sessionRunning()` |
+| ✅ 处理 | `core/chat.service.ts::onTurnReaction` 回调 |
+| 本地标记清除 | `clearSessionLocalStartFor()` 唯一调用点 = 收到 ✅ 时 |
+
+*本文所有数据为 2026-11-12 实测,非推断。*

+ 260 - 0
docs/00-tracking/2026-11-12-环境启动与全链路诊断.md

@@ -0,0 +1,260 @@
+# 环境启动与全链路诊断(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 执行时间 | 2026-11-12(本机 `E:\work\fmode-app`,Windows 11,Node v22.23.2 / npm 10.9.8) |
+| 方式 | **只读诊断**:装依赖、启动、跑测试、浏览器实测、云函数回读。**未改任何业务代码 / 配置 / 容器** |
+| 被测容器 | `agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`,CLAUDE.md 强制) |
+| 关联文档 | [项目进度与资源盘点](2026-11-12-项目进度与资源盘点.md) · [遗留问题-多用户准入未打通](2026-11-12-遗留问题-多用户准入未打通.md) · [容器接入标准流程-SOP-v3](../03-interface/容器接入标准流程-SOP-v3-20261112.md) |
+| 一句话结论 | **项目能在全新环境一条命令拉起、1683 条单测全过、双端真实数据渲染无报错;卡点不在 App 代码,在「线上未部署 + 容器侧配置与状态口径」** |
+
+---
+
+## 一、安装与启动实录(可复现)
+
+### 1.1 安装
+
+| 步骤 | 命令 | 结果 |
+|---|---|---|
+| 依赖安装 | `npm ci` | ✅ **792 个包 / 22 秒**;`postinstall` 自动生成 `public/pdf.worker.js` |
+| 依赖校验 | `npm ls --depth=0` | ✅ 30 个直接依赖全部就位,无 `UNMET`;Angular 实装 **20.3.33**、`fmode-ng 0.0.246`、`matrix-js-sdk 42.4.0`、Tailwind 3.4.19 |
+| 体积 | — | `node_modules` **655 MB**(首次拉取耗时主要受网络影响) |
+
+唯一的告警是 7 条 npm `deprecated` 提示(`inflight` / `rimraf@3` / `glob@7` / `crypto-js` / `core-js@2` 等),全部来自 **间接依赖**,不是本项目直接声明的,**不影响运行**。
+
+### 1.2 启动
+
+| 步骤 | 命令 | 结果 |
+|---|---|---|
+| 开发服务器 | `npm start`(`ng serve`,development 配置,真实后端) | ✅ 编译通过 |
+| 编译告警 | — | **0 error**;10 条 `NG8113`(模板里 import 了但没用的组件) |
+| 首屏体积 | — | `main.js` 85.22 kB、`styles.css` 187.59 kB、其余按路由懒加载 |
+| HTTP | `GET /`、`GET /desktop/chat` | ✅ 均 **200** |
+
+**10 条 NG8113 明细**(死代码,建议后续清理,不影响功能):
+
+```
+SessionTreeComponent: UiEmpty, UiInput       DkChatPage: UiInput, FilePreviewComponent, PresenceBadgeComponent
+DkContactsPage: StatusBadgeComponent         DkNavComponent: UiAvatar
+MbChatPage: ToolActivityComponent            MbProjectsPage: MAvatarComponent
+MbWorkbenchPage: MChipsComponent
+```
+
+> ⚠️ **环境层面的两处不一致(本次未处理,建议处理)**:
+> 1. 仓库里同时存在 `package-lock.json`(npm)与 `pnpm-lock.yaml` + `pnpm-workspace.yaml`。`angular.json` 的 `cli.packageManager` 与 CLAUDE.md 都写 **npm** → **pnpm 两件套是陈旧残留,建议删除**,否则后来者可能装出不一致的依赖树。
+> 2. `src/environments/environment.local.ts` 不存在(只有 `.example`)→ **`npm run start:local` 现在跑不起来**。日常 `npm start` 不受影响。
+
+---
+
+## 二、测试与验证基线(本次实测)
+
+| 验证项 | 命令 | 结果 |
+|---|---|---|
+| 单元测试 | `npx ng test --watch=false --browsers=ChromeHeadless` | ✅ **1683 / 1683 SUCCESS**(4.25 秒) |
+| UI 规范断言 | `npm run verify:ui` | ⚠️ **10 / 12 通过**(见 §五 体验问题 U-07 / U-08) |
+| 双端渲染(真实登录) | Playwright + `.claude/dev-login.json` | ✅ 桌面 6 条路由 + 移动 4 条路由:**pageerror 0、失败请求 0、无横向溢出** |
+| 入口分端判定 | 4 种设备 | ✅ 1440→`/desktop/chat`、390(iPhone UA)→`/mobile/messages`、820 窄窗→桌面、1024 iPad→桌面 |
+| 容器在线 | `node scripts/matrix-check.mjs agent-node-lj-test` | ✅ 记录 / 可见性 / 上报三项通过(「收发闭环」脚本不代发消息,本次未测) |
+| 网络延迟 | 本机直连 | DNS 297 ms、云函数往返 **233 ms**、Parse `/users/me` **52 ms** |
+
+**双端渲染实测数据**(首屏可交互耗时 / 页面文本量):
+
+| 桌面路由 | 耗时 | 移动路由 | 耗时 |
+|---|---|---|---|
+| `/desktop/chat` | 920 ms | `/mobile/messages` | 632 ms |
+| `/desktop/contacts` | 515 ms | `/mobile/contacts` | 424 ms |
+| `/desktop/workbench` | 930 ms | `/mobile/workbench` | 884 ms |
+| `/desktop/agent/agent-node-lj-test` | 427 ms | `/mobile/mine` | 411 ms |
+| `/desktop/mine` | 797 ms | — | — |
+| `/desktop/admin/users` | 521 ms | — | — |
+
+页面里是**真实后端数据**(会话树出现「今天南昌天气怎么样?」等真实会话、工作台显示 CPU 6.8% / 内存 46.5%、通讯录显示 3 位同事),不是 mock。
+
+---
+
+## 三、进度在哪里
+
+### 3.1 总体判断
+
+| 维度 | 状态 | 本次验证依据 |
+|---|---|---|
+| 代码完整性 | ✅ 完整,能编译、能跑 | `npm ci` + `npm start` 通过 |
+| 单测 | ✅ 1683 条全过 | 实测 |
+| 双端 UI | ✅ 两端一致可用 | 真实数据渲染实测 |
+| 容器接入 | ⚠️ 主容器 `lj-test` 通,其余容器状态口径混乱 | 云函数回读(§六) |
+| 线上部署 | 🔴 **落后多个提交** | 盘点文档自述,本次未部署 |
+| 文档 | ⚠️ 数量充足但存在过期与自相矛盾 | §五 D-01 ~ D-06 |
+
+### 3.2 已完成(本次复核确认)
+
+| 主题 | 能力 | 本次复核 |
+|---|---|---|
+| 会话状态 | `resolveSessionPhase()` 单点裁决,reaction > session > typing > turn | ✅ 代码在 `core/agent-liveness.ts` |
+| 结束信号 | ✅/❌ reaction | ✅ 容器实时数据在正常刷新 |
+| 长任务 | 有步骤 + 无信号 → 持续「输出中」 | ✅ `TURN_TIMEOUT_MS = 300_000`(**注意与交接文档不符,见 D-01**) |
+| 多会话隔离 | `currentSessions[].userId` 区分谁在跑 | ✅ 代码在 `core/agent-state.ts` |
+| 断线恢复 | SDK keepAlive 自动重连 | ✅ 实测:断网 40 秒 + 恢复 20 秒内**自动恢复**(`/sync` 继续发出) |
+| 文件与成果 | file-hub 台账、按会话/群隔离 | ✅ 云函数 `THXAOGKiPK` v0.2.0 在仓 |
+| 多用户 | provision 同公司可开通 | ✅ `fmodeagent-matrix-provision.js:423` 已含同公司判定 |
+
+---
+
+## 四、当前遇到的困难(按能不能自己解决分档)
+
+| # | 困难 | 归属 | 现状 | 影响 |
+|---|---|---|---|---|
+| **H-01** | **线上未部署最新代码** | 本项目 | 线上落后 4 个提交(盘点文档自述) | 🔴 用户看到的是旧行为,本次所有改进都没到用户手上 |
+| **H-02** | `agent-lj` 准入被 s6 注入卡死 | **平台侧** | 容器内 5 种改法全失败(白名单/置空/删行/ALLOW_ALL/删 s6 文件) | 🔴 同公司同事**用不了 `lj`**,能进会话但容器不回 |
+| **H-03** | 多个容器状态为陈旧残留 | 容器侧 | `lj`/`liujun`/`5d63c5bf2cd9` 心跳停了 **8 小时以上**,`status` 仍是 `running` | 🟡 工作台/通讯录显示「正在执行」,实为幽灵状态 |
+| **H-04** | 非标准状态值 `online` 进入系统 | 容器/云函数 | `agent-node-app-lj-test-v1` 上报 `status: "online"`,不在 `AgentStatus` 五值里 | 🟡 类型被 `as AgentStatus` 强转,文案层无对应项 |
+| **H-05** | 心跳字段正常、`host` 指标 5 天未更新 | 容器侧 | `lj-test` 心跳 **21 分钟前**(正常),但 `host.probedAt` 停在 **11-07** | 🟡 工作台 CPU/内存显示 5 天前的值,用户误以为实时 |
+| **H-06** | 测试数据清不掉 | 云函数侧 | `file-hub` 无 `remove`;`__probe__`、5 个临时容器留在列表中 | 🟡 通讯录/工作台列表脏 |
+| **H-07** | `thread_id` 覆盖率约 54% | 容器侧 | 未覆盖的靠 roomId 退路匹配 | 🟡 同一房间多会话时状态会串(已记为可接受取舍) |
+| **H-08** | 任务看板无数据源 | 平台侧 | `fetchTasks` 直接抛错(`parse-api.ts:455`) | 🟡 任务入口点击后报「数据源未就绪」 |
+| **H-09** | 环境残留(pnpm 锁文件 / 缺 `environment.local.ts`) | 本项目 | 见 §1.2 | 🟢 不影响主流程,但会误导后来者 |
+
+---
+
+## 五、使用体验问题(用户在真实使用中会碰到的)
+
+> 编号规则:**U = 用户体验问题**,**D = 文档问题**。
+
+### 5.1 U 类(体验)
+
+| # | 现象 | 证据 | 严重度 |
+|---|---|---|---|
+| **U-01** | **心跳判活与实时在线不一致** | `lj-test` 心跳 21 分钟前但 `host.probedAt` 是 11-07;`agent-lj` 心跳停 8 小时仍显示 `running` | 🔴 |
+| **U-02** | **幽灵「正在执行」** | `agent-lj` 有 1 条 `running` 记录 `ageMin=492`;App 兜底 10 分钟后回落到「不知道」(`agent-state.ts:431`),但**10 分钟内仍会转圈** | 🔴 |
+| **U-03** | **「在线」状态值没有文案** | 容器上报 `online`,`AGENT_STATUS_TEXT`(`models.ts:707`)只有 running/idle/error/offline/stopped 五个键 | 🟡 |
+| **U-04** | **余额文案与实际语义不符** | 6 个容器显示同一个余额(894.13),实际是**主人账号余额**,不是容器余额 | 🟡 |
+| **U-05** | 任务看板点了就报错 | `fetchTasks` 抛「云函数接收端尚未实现」 | 🟡 |
+| **U-06** | 首字延迟 5~9 秒,用户以为是模型慢 | SOP 实测首字 2~9 秒;DNS 卡顿会额外加 5 秒。本次本机 DNS 297 ms 正常 | 🟡 |
+| **U-07** | **UI 无遮挡检查未覆盖 max-height 容器** | `verify:ui` AC-12.6 只统计 direct import,App 通过 `UiIconComponent` 等包装组件间接使用底座导致覆盖率被低估;**且 165 处视觉高度 <44px 的声明需人工复核** | 🟡 |
+| **U-08** | **硬编码色值 7 处** | `npm run verify:ui` AC-12.1 报 7 处(目标 0)。其中:6 处是 `&#126;` 的测试断言(**误报**,非 CSS 颜色),1 处是 `mobile/pages/admin/users/users.ts:316` 的 `var(--mask, rgba(0,0,0,0.4))` —— **`--mask` 令牌在 ui-kit 中不存在**(`styles.uikit.css` 只有 `--z-mask`),兜底值永远生效,且绕过了底座已有的 `--overlay` | 🟢 |
+| **U-09** | 移动端 `<768px` 自动跳桌面,无「强制桌面」入口 | 用户裁决 2026-11-10 去掉手动按钮;`detectUiMode()` 只看 `innerWidth` | 🟢(按裁决保留) |
+
+### 5.2 D 类(文档问题)
+
+| # | 问题 | 证据 |
+|---|---|---|
+| **D-01** | 交接文档写 `TURN_TIMEOUT_MS = 180_000`,**代码是 300_000** | `2026-11-10-交接-未完成事项清单.md` §4.4 vs `core/assistant-turn.ts:555` |
+| **D-02** | 容器接入指南表格有**重复行** | `容器接入完整指南-v2-20261111.md` 第 24 行与第 30 行内容完全相同 |
+| **D-03** | SOP v3 **未覆盖 s6 注入优先于 `.env`** | SOP §3.0.5 只写「`.env` 可能没加载进进程」,未写「s6 注入会覆盖 `.env` 且用户无权删除」。新容器按 SOP 配会**完全失效**(`agent-lj` 即是) |
+| **D-04** | 心跳标准 3600s + 抖动 0~900s 与 App 80 分钟阈值**余量极薄** | 最坏情况 3600+900=4500s=75 分钟,只比 80 分钟少 5 分钟;抖动再来一次累计就误判离线 |
+| **D-05** | `5d63c5bf2cd9` 容器 `containerRef` 是 objectId,说明可能是老流程产物或 create 参数错误 | 云函数回读 |
+| **D-06** | `.tmp-details.mjs` / `.tmp-drawer.mjs` / `.tmp-flow3.mjs` / `.tmp-routes.mjs` **被 git 跟踪**,操作追踪记录过这类文件应进 `.gitignore` | `git ls-files` |
+| **D-07** | `verify:docs` 报 1 处结构问题:`多用户共用容器-完整规范-20261112.md:168` 的 shell 行 `|| echo ...` 以 `|` 开头,被判定为未闭合表格行 | `npm run verify:docs`(该问题在本次之前已提交进 `735f3b3`,非本次引入) |
+
+---
+
+## 六、容器端流程梳理与准确性评估
+
+### 6.1 端到端流程(每环节带判定依据)
+
+```
+用户(App,@xhvrjtmwqh)         容器 agent-node-lj-test (@zgdcmgnqcd)
+   │                                    │
+   │ ① 发起:点新建对话 → provision RP5Ht55NeX(同公司校验)
+   │ ─────────────────────────────────► │ 建 Matrix 身份 + DM 房间 + 写绑定
+   │                                    │
+   │ ② 发消息:Matrix DM(m.room.message)
+   │ ─────────────────────────────────► │ ③ 准入判定 authz_mixin._principal_authorized
+   │                                    │    白名单非空 → 只认名单
+   │                                    │    否则 → GATEWAY_ALLOW_ALL_USERS
+   │                                    │ ④ turn:模型 + 工具
+   │ ◄────────── ⑤ 正文 / 图片 / 文件 ── │
+   │ ◄────────── ⑥ ✅/❌ reaction ────── │  Hermes on_processing_complete()
+   │                                    │ ⑦ 状态上报 xlosi3m46q:field="extra"
+   │                                    │     {currentSessions:[...]}
+   │                                    │ ⑧ 心跳 heartbeatAt(3600s ± 900s)
+   │                                    │ ⑨ 文件上云 AlP56LCKFm → OBS
+   │                                    │ ⑩ 登记台账 THXAOGKiPK(带 threadRoot)
+   │                                    │ ⑪ 会话目录 ChatThread oR5O67i1Cm
+   ▼                                    ▼
+App 渲染 / 成果栏                   FmodeAgent 表 + AgentDeliverable 表
+```
+
+### 6.2 准确性评估(✅ 实测 / ⚠️ 存疑 / ❓ 待回填)
+
+| 环节 | 判定 | 依据 |
+|---|---|---|
+| ① provision 同公司放行 | ✅ | 云函数源码 `fmodeagent-matrix-provision.js:423` 已上线,代码含 `_isSameCompany` |
+| ② Matrix 收发 | ✅ | `matrix-check.mjs` 三项通过;`lj-test` 消息在 App 里正常出现 |
+| ③ 准入 | ⚠️ **两种行为并存** | `lj-test`(10-17 创建,无 s6 注入)走 ALLOW_ALL 生效;`lj`(10-21 创建,有 s6 注入)白名单压过 ALLOW_ALL,同事被拒 |
+| ④ turn | ✅ | 实测收到真实回复(会话树有真实对话) |
+| ⑤ 正文格式 | ✅ | 会话列表可见 |
+| ⑥ ✅ reaction | ✅ | `verify-reaction-coverage.mjs` 实测,晚于正文 0.1 秒 |
+| ⑦ 状态上报 | ⚠️ `lj-test` 心跳在跳(21 分钟前更新),但 `host.probedAt` 5 天未更新 | `currentSessions: []` 是因为当时无活动会话,**不一定是漏报** |
+| ⑧ 心跳 | ⚠️ 3600s 标准与 80 分钟阈值余量仅 5 分钟 | 真实风险:若某次上报迟到,就被误判离线 |
+| ⑨ 文件上云 | ❓ 本次未实测(未向容器发文件,只读约束) | 需容器回填 `artifact-drain.py` 是否存在 |
+| ⑩ 台账 | ✅ 云函数在仓(v0.2.0) | 代码存在,链路未发文件验证 |
+| ⑪ 会话目录 | ✅ 云函数在仓 v0.1.9 | 代码存在 |
+
+### 6.3 提升空间与具体解法
+
+按「**平台侧必须做 / 容器侧可做 / App 侧兜底**」分档:
+
+#### 🔴 P0 · 平台侧(不解决就永远有一批容器不可用)
+
+| # | 问题 | 解法 | 代价 |
+|---|---|---|---|
+| **P-01** | s6 注入 `MATRIX_ALLOWED_USERS` | 平台删除 `/run/s6/container_environment/MATRIX_ALLOWED_USERS` 后重启;或**新建容器时不注入** | 不影响数据;需平台操作 |
+| **P-02** | 部署最新代码 | `npm run deploy`(本次已确认 `dist/test-out` 由测试生成,生产部署会重新 build) | 低 |
+| **P-03** | 任务看板数据源 | 云函数接收端实现 `fetchTasks` | 中 |
+
+#### 🟡 P1 · 容器侧(配置与状态口径)
+
+| # | 问题 | 解法 | 代价 |
+|---|---|---|---|
+| **C-01** | `host.probedAt` 5 天未更新 | 心跳周期里重新采集 host 指标,或把这个字段标记为「仅部署时采样」并让 App 显示「XX 天前」 | 低 |
+| **C-02** | `running` 残留 | `status-report.py` 判据从 `last_activity_at` 改为**消息表最新时间戳**,或引入 turn lease(§5.3.1 已给出方案) | 中 |
+| **C-03** | 陈旧容器不上报心跳 | 容器离线时主动报 `status: "offline"` 或 `stopped`(让 App 能区分「停了」和「没上报」) | 低 |
+| **C-04** | 非标准状态值 `online` | 统一为五值之一 | 低 |
+
+#### 🟢 P2 · App 侧兜底(不依赖别人、自己能改)
+
+| # | 问题 | 解法 | 改动位置 | 代价 |
+|---|---|---|---|---|
+| **A-01** | `--mask` 令牌不存在,兜底 `rgba(0,0,0,0.4)` 永远生效 | 改用底座已有的 `var(--overlay)`(`styles.uikit.css:351`),删除兜底色值 | `mobile/pages/admin/users/users.ts:316` | 1 行 |
+| **A-02** | `online` 状态无文案 | `AGENT_STATUS_TEXT` 加 `online: '在线'`;或类型层拒绝非五值 | `core/api/models.ts:707` | 1 行 |
+| **A-03** | 心跳判活与 `host` 新鲜度不一致 | 工作台显示 CPU 时用 `host.probedAt` 自身时间做「X 天前」标注,不要用 `heartbeatAt` 冒充 | `desktop|mobile/pages/workbench` | 小 |
+| **A-04** | 任务入口报错文案 | 已是明确报错(非静默),可在入口处直接标「暂未开放」而不让用户点进去 | `pages/mobile-tasks` 相关 | 小 |
+| **A-05** | 心跳 3600s 标准与 80 分钟阈值余量薄 | **建议**与容器侧协商:心跳周期回调到 45~60 分钟,或把 App 阈值提到 100 分钟 | 双方协商 | 低 |
+
+### 6.4 本次**未能验证**的项(如实披露)
+
+| # | 项 | 原因 | 如何补 |
+|---|---|---|---|
+| V-01 | 文件上云全链路(产出 → OBS → 台账 → 回复带链接) | 只读约束,未向容器发文件 | 在 App 里新建会话发一个带附件的需求 |
+| V-02 | 图文合并(`plugin-media-text-merge`)是否启用 | 只能在容器内看 `config.yaml` | 容器回填或平台查看 |
+| V-03 | DNS 解析耗时(容器内) | 容器内命令需容器执行 | 按 SOP 第 0 步自测 |
+| V-04 | `artifact-drain.py` 是否存在 | 同上 | 容器回填 |
+| V-05 | 收发闭环(发一条消息看回) | 本次不代发消息 | 人工在 App 里发一条 |
+| V-06 | 线上部署版本 | 未部署 | 部署后回读 |
+
+---
+
+## 七、建议的下一步(按优先级)
+
+1. **部署线上**(P-02)——用户看到的所有问题从这里开始有变化
+2. **平台删 s6 注入 + 重启 `agent-lj`**(P-01)——让同事能用
+3. **App 侧 3 个小修**(A-01/A-02/A-03)——全部是 1~3 行级改动,可一并做
+4. **容器侧补 `host` 采集 + `running` 残留**(C-01/C-02)
+5. **清理环境残留**(D-06 的 4 个 `.tmp-*.mjs`、pnpm 两件套、补 `environment.local.ts`)
+
+> 本次**未执行任何改动**(只读诊断)。以上为建议,执行前请裁决。
+
+---
+
+## 八、附:本次实测命令清单(可复现)
+
+```bash
+npm ci                                                       # 792 包 / 22s
+npm start                                                    # 0 error, 10 NG8113
+npx ng test --watch=false --browsers=ChromeHeadless          # 1683 SUCCESS
+npm run verify:ui                                            # 10/12
+node scripts/matrix-check.mjs agent-node-lj-test             # 3 项通过 + 1 项人工
+# 浏览器:Playwright(channel=chrome) + .claude/dev-login.json 注入
+```
+
+*本文所有数字均为 2026-11-12 本机实测,非估算;未验证项已单列 §6.4。*

+ 65 - 0
docs/00-tracking/操作追踪.md

@@ -7882,3 +7882,68 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
    必须回读 `/proc/<pid>/environ`。
 3. **非空白名单优先于 ALLOW_ALL**:这与直觉相反,配错会导致「以为放开了其实没放开」。
 4. **线上代码可能与本地备份不一致**:部署前必须先拉线上版本核对。
+
+## 2026-11-12 · 环境启动 + 全链路只读诊断
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 在全新环境安装依赖、启动项目、跑测试与真实浏览器双端验证、云函数回读,产出诊断报告 |
+| 原因 | 用户要求:确认项目能正确启动;盘点当前进度、困难、体验问题与优化方向;梳理容器端流程并判断准确性 |
+| 前提裁决 | 范围=只读诊断(不改业务代码);启动=仅真实模式;`.tmp-*.mjs` 只在报告列出不清理 |
+| 安装 | `npm ci` → 792 包 / 22s;postinstall 生成 `public/pdf.worker.js`;`npm ls --depth=0` 无 UNMET |
+| 启动 | `npm start` 编译通过,0 error / 10 条 NG8113;`/` 与 `/desktop/chat` 均 200 |
+| 验证 | `ng test` **1683 SUCCESS**;`verify:ui` 10/12;Playwright 双端 10 条路由 pageerror 0、失败请求 0;入口分端 4 种设备判定正确;断网 40s 后自动恢复 |
+| 容器回读 | `lj-test` 心跳 21 分钟前(正常);`lj`/`liujun`/`5d63c5bf2cd9` 心跳停 8 小时以上但 status 仍 running;`app-lj-test-v1` 报非标准值 `online` |
+| 主要发现 | ① 心跳 3600s+900s 抖动与 App 80 分钟阈值余量仅 5 分钟;② `--mask` 令牌不存在致 `rgba(0,0,0,0.4)` 永远生效(应改 `--overlay`);③ `online` 状态无文案;④ SOP v3 未覆盖 s6 注入优先于 `.env`;⑤ 交接文档超时 180s 与代码 300s 不符 |
+| 未验证项 | 文件上云全链路、图文合并开关、容器内 DNS、`artifact-drain.py`、收发闭环、线上版本(详见报告 §6.4) |
+| 涉及文件 | 新增 `docs/00-tracking/2026-11-12-环境启动与全链路诊断.md`;本文件仅追加本条 |
+| 约束遵守 | 未改业务代码、未改配置、未动容器、未提交、未部署 |
+| 关联文档 | [2026-11-12-环境启动与全链路诊断](2026-11-12-环境启动与全链路诊断.md)、[2026-11-12-项目进度与资源盘点](2026-11-12-项目进度与资源盘点.md) |
+
+## 2026-11-12 · lj 容器恢复核查 + 左侧状态转圈修复
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 与 `agent-lj` 实聊取证;以同事身份真实复现;修复左侧会话条目转圈 | 
+| 原因 | 用户反馈「同事用不了 lj」;「左侧点开才停、不点开一直转,应是 App 端问题」 |
+| lj 结论 | 实测 lj 在 11-11 17:19Z 后**一直没在跑**(绑定房间里主人连发 4 条 0 响应、0 reaction);用户补齐启动后,同事「你好」得到 `👀 → 正文 → ✅`,准入规则本身正常 |
+| 铁证 | lj 房间里留下 `Dropped a message from unrecognized matrix user pnfapmsbhs` 一条 |
+| 凭据踩坑 | 用户 Matrix 密码 = `sha256("fmode-mx:" + 该用户自己的 sessionToken)`,**不能从 mxid 推导**(写了 3 次错误猜测) |
+| App 修复 | ①`sessionRunning()` 增加 `localStartAt` 参数,**陈旧 idle 不采信**;②✅ reaction 携带 `targetId`,按消息反推会话清标记(不再只认当前会话);③新增会话级 `sessionIsWriting()`;④模板读 `live.tick()` 让时间流逝后重算 |
+| 涉及文件 | `core/agent-state.ts`、`core/chat.service.ts`、`core/task-pulse.service.ts`、`core/matrix-client.service.ts`、`components/domain/session-tree/session-tree.ts`、`core/agent-state.spec.ts` |
+| 验证 | `tsc` ✅ / `ng build --configuration development` ✅ / `ng test` **1686 SUCCESS**(新增 3 条回归) |
+| 教训 | ①dev server 编译失败时浏览器仍跑旧包,排查前必须先看构建输出;②`tsc --noEmit` 测不出模板访问 private 成员,必须 `ng build`;③`TaskStop` 后端口不释放,需 `taskkill` |
+| 遗留 | 容器侧 `turnStartAt` 与 `updatedAt` 只差 1 秒(语义失真);停机时仍报 `running`;左侧转圈的浏览器级端到端采样未取得 |
+| 关联文档 | [2026-11-12-lj恢复与左侧转圈修复](2026-11-12-lj恢复与左侧转圈修复.md) |
+
+## 2026-11-12 · 历史会话打不开(根因与修复)
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 排查「历史记录都无法查看」,修复按会话回溯 + 空态三态 + 连接断开自愈 |
+| 原因 | 用户反馈 lj-test 历史会话打开后显示「没有可加载的消息」 |
+| 结论 | **数据没丢**。50/51 条会话内容完好,用户已实测确认可正常查看 |
+| 主因 | `startClient({ initialSyncLimit: 50 })` 只留房间最近 50 条,`timelineToMessages()` 又从不向前翻 → 老会话消息不在本地 |
+| 次因 | 空态判据 `rawTimeline.length > 0` 恒为真,把「取不到」伪装成「全新会话」欢迎页 |
+| 数据盘点 | 目录 51 条 / Matrix 线程根 104 个 / 两边都有 50 条 / 空壳 1 条(「测试9680」,Matrix 里 0 条消息)/ 只在 Matrix 54 个 |
+| 修复 | ①`backfillSessionTimeline()` 按会话回溯(先 `synced()`、命中即停、与 `paginate()` 共用锁);②`_emptyReason` 增加 `blank` 三态;③`MatrixClientService` 对 `M_UNKNOWN_TOKEN` 自动重签凭据自愈 |
+| 涉及文件 | `core/chat.service.ts`、`core/matrix-client.service.ts`、`desktop/pages/chat/chat.ts`、`mobile/pages/chat/chat.ts`、`core/chat.service.spec.ts`、新增 `core/matrix-client.service.spec.ts` |
+| 验证 | `tsc` ✅ / `ng build` ✅ / `ng test` **1695 SUCCESS**(新增 4 条)/ 浏览器抽样 5 条 / 用户实测确认 |
+| 教训 | ①`paginateEventTimeline` 返回 false = 到尽头而非出错(读 SDK 源码确认);②`paginationToken()` 返回 null 既可能是到顶也可能是 store 未就绪;③`rm .tmp-*.mjs` 误删了 git 跟踪的 4 个调试脚本,已恢复,清理须用精确文件名 |
+| 遗留 | 冷启动下深度回溯未翻到房间最早(只影响「空壳+冷启动」,不影响有内容会话);「测试9680」软删本轮未执行 |
+| 关联文档 | [2026-11-12-历史会话打不开-根因与修复](2026-11-12-历史会话打不开-根因与修复.md) |
+
+## 2026-11-12 · 新容器接入优化文档(对齐 lj-test 基线)
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 产出《新容器接入优化-对齐lj-test基线》,含行为基线、Matrix 配置、上报协议、多人共用反面案例、四阶段提示词 |
+| 原因 | 用户要求:让后续容器按同一文档接入,避免每次重新排查;并覆盖多人共用打不通的问题 |
+| 依据 | 2026-11-12 实测:容器 5s 报 running / 7s 报 idle;心跳 3600s+0~900s 抖动;✅ 只代表单条消息发完;客户端自建房间容器 3s 自动进房;`_thread_root()` 取不到即跳过登记 |
+| 关键提示 | **改了插件不重启 gateway → 新代码完全不生效**(lj-test 实测:文件 mtime 比进程启动晚 12.5 小时) |
+| 四阶段提示词 | ① 自查(只读,14 项)→ ② 按差距修改(改完必须重启 + 回读进程环境)→ ③ 自测审核(8 项)→ ④ 联调实测(App 侧独立回读) |
+| 涉及文件 | 新增 `docs/03-interface/新容器接入优化-对齐lj-test基线-20261112.md`;`README-容器接入文档索引.md` 更新必读清单;本文件追加 |
+| 验证 | `npm run verify:docs` 新文档无结构问题(唯一报错在既有旧文件) |
+| 约束遵守 | 未改任何代码、未重启容器、未提交 |
+| 关联文档 | [新容器接入优化-对齐lj-test基线](../03-interface/新容器接入优化-对齐lj-test基线-20261112.md) |
+

+ 4 - 3
docs/03-interface/README-容器接入文档索引.md

@@ -9,9 +9,10 @@
 
 | # | 文档 | 用途 | 谁看 |
 |---|---|---|---|
-| **1** | **`容器接入标准流程-SOP-v3-20261112.md`** | **操作单**:体检 → 修改 → 自审 → 复验 → 交付 | **人**(照着做) |
-| **2** | **`提示词-容器自我优化-v2-20261111.md`** | **发给容器的提示词**,7 项自查 | **容器**(直接发) |
-| **3** | **`多用户共用容器-完整规范-20261112.md`** | 同公司多人共用容器的完整链路 | 人(特殊场景) |
+| **1** | **`新容器接入优化-对齐lj-test基线-20261112.md`** | **新容器接入的唯一执行文档**:行为基线 + 配置 + 上报协议 + 多人共用反面案例 + **四阶段提示词** | **容器**(按阶段执行) |
+| **2** | **`容器接入标准流程-SOP-v3-20261112.md`** | **操作单**:体检 → 修改 → 自审 → 复验 → 交付 | **人**(照着做) |
+| **3** | **`提示词-容器自我优化-v2-20261111.md`** | 早期版提示词(已被第 1 份的四阶段提示词取代) | ⚠️ 保留备查 |
+| **4** | **`多用户共用容器-完整规范-20261112.md`** | 同公司多人共用容器的完整链路 | 人(特殊场景) |
 
 ---
 

+ 886 - 0
docs/03-interface/新容器接入优化-对齐lj-test基线-20261112.md

@@ -0,0 +1,886 @@
+# 新容器接入优化 · 对齐 lj-test 基线(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 用途 | 让**任意新容器**按本文步骤,达到与 `agent-node-lj-test` 一致的 App 回复效果 |
+| 参考实现 | `agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`) |
+| 依据 | 2026-11-12 逐项实测(真实接口调用 + 容器自述 + 代码取证),非推断 |
+| 配套 | `容器接入标准流程-SOP-v3-20261112.md`(体检流程)、`多用户共用容器-完整规范-20261112.md`(准入) |
+
+> **本文与 SOP v3 的关系**:SOP v3 讲「怎么体检、怎么改」,本文讲
+> **「改完怎么才算达标」**、**「为什么改完还是不生效」**、**「多人合用的坑」**,
+> 以及**一份可直接交给新容器执行的四阶段提示词**(第六节)。
+
+---
+
+## 一、目标状态:lj-test 的行为基线
+
+接入完成后,**以下 7 项必须全部满足**。任何一项不满足,App 里就会有相应的显示异常。
+
+| # | 能力 | 期望表现 | 实测方法 |
+|---|---|---|---|
+| 1 | **回复** | 收到消息后回正文 | App 里直接可见 |
+| 2 | **✅ reaction** | 每条消息发出后打 ✅ | `/sync` 读到 `m.reaction` |
+| 3 | **状态上报(事件驱动)** | turn 开始/结束**立即**发 HTTP | 轮询 `xlosi3m46q`,**5~10 秒内** status 变化 |
+| 4 | **心跳(保活)** | **3600s + 0~900s 抖动** | 10 分钟内 ≤1 次上报 |
+| 5 | **`currentSessions`** | 每会话 6 字段齐全 | 回读 `extra` |
+| 6 | **文件交付** | 正文末尾带「已上云」链接 | 让它产出一个文件 |
+| 7 | **自动入房** | 收到 invite 自动加入 | `joined_members` 里有它 |
+
+### 1.1 ⚠️ 最重要的一条:✅ ≠ 回合结束
+
+lj-test 的**原话**:
+
+> 「Matrix ✅(`m.reaction`):由 adapter 在 `send()` 完成后立即添加,
+> 表示**这条消息**已经处理完,**不代表对话轮次结束**。」
+
+| 信号 | 真实含义 | 谁能用 |
+|---|---|---|
+| `✅` reaction | **单条消息**投递完成 | 仅用于「消息已发出」 |
+| `extra.currentSessions[].status` 变 `idle` | **本轮**处理完成 | App 用这个判「回复结束」 |
+
+**如果一个长任务分多条发送,每条都会有一个 ✅。**
+把第一个 ✅ 当回合结束,App 会提前停止转圈,用户以为完成了、正文却还在输出。
+
+### 1.2 实测时序(lj-test,可作为新容器的对照)
+
+```
+T+0s   用户发送
+T+5s   容器上报 status=running     ← 事件驱动
+T+7s   容器上报 status=idle        ← 本轮结束
+```
+
+**结论:容器的状态上报足够快(5~7 秒)。** 如果新容器实测超过 30 秒,
+说明是上报机制有问题,不是"正常延迟"。
+
+---
+
+## 二、Matrix 配置(达到 lj-test 效果的必需项)
+
+### 2.1 配置清单(`/opt/data/.env`)
+
+```bash
+# ---- 身份与连接 ----
+MATRIX_HOMESERVER=https://matrix.fmode.cn
+MATRIX_USER_ID=@xxx:fmode.cn
+MATRIX_ACCESS_TOKEN=xxx
+MATRIX_DEVICE_ID=xxx
+
+# ---- 必须开启 ----
+MATRIX_REACTIONS=true                # ✅ 每条消息的完成标记(App 用它显示"消息已到")
+MATRIX_AUTO_THREAD=true              # 建议开启
+
+# ---- 多人共用(关键)----
+GATEWAY_ALLOW_ALL_USERS=true         # 全放开
+MATRIX_ALLOWED_USERS=                # 🔴 必须留空,见 §5
+
+# ---- 调云函数用 ----
+FMODE_SESSION_TOKEN=r:xxx            # 主人 token
+AGENT_ID=agent-xxx
+```
+
+### 2.2 准入判定顺序(实测,与直觉相反)
+
+```
+① MATRIX_ALLOWED_USERS 非空?
+     是 → 只认名单,GATEWAY_ALLOW_ALL_USERS 被完全忽略
+     否 → 看 ②
+② GATEWAY_ALLOW_ALL_USERS = true?
+     是 → 全部放行
+     否 → 拒绝
+```
+
+**只要白名单非空,ALLOW_ALL 就等于没开。** 这是最容易配错的地方。
+
+### 2.3 🔴 改完必须重启 gateway
+
+**这是本会话排查最久的一个坑,新容器必读。**
+
+实际案例(lj-test,2026-11-12):
+
+```
+插件文件 __init__.py 修改时间:13:36:15
+gateway 进程启动时间:      01:07:19   ← 早 12.5 小时
+```
+
+结果:**新代码一行都没生效**。日志里既没有成功记录、也没有失败记录 ——
+因为旧代码里根本没有那个函数,自然不会打任何日志。
+
+**判断方法(改完立刻做)**:
+
+```bash
+# 1. 看文件和进程谁新
+stat -c '%y %n' /opt/data/plugins/plugin-active-reporter/__init__.py
+ps -o lstart= -p $(pgrep -f "gateway run" | head -1)
+```
+
+**文件修改时间晚于进程启动时间 → 改动未加载,必须重启。**
+
+```bash
+# 2. 重启(二选一)
+s6-svc -r /run/service/gateway-default
+# 或
+/opt/hermes/.venv/bin/hermes gateway run --replace
+
+# 3. 回读进程环境,确认配置真的进去了
+PID=$(pgrep -f "gateway run" | tail -1)
+tr '\0' '\n' < /proc/$PID/environ | grep -E "MATRIX_ALLOWED|ALLOW_ALL|REACTIONS"
+```
+
+> ⚠️ 重启会**打断正在进行的对话**。批量改完一次重启,不要改一项重启一次。
+
+### 2.4 能力边界
+
+| 路径 | 权限 | 能做什么 |
+|---|---|---|
+| `/opt/hermes` | `root:root`,**不可写** | 只能读 |
+| `/opt/data` | 容器用户属主 | 插件、配置、脚本 |
+| `/etc/hosts`、`/etc/resolv.conf` | root 只读 | 改不了 |
+| `/run/s6/container_environment/` | root:root | **改不了**(见 §5.2) |
+
+---
+
+## 三、状态上报协议
+
+### 3.1 🔴 两类上报必须分离(用户明确要求)
+
+| 类型 | 触发 | 间隔 | 内容 |
+|---|---|---|---|
+| **心跳(保活)** | 定时 | **3600s + 0~900s 抖动** | `heartbeatAt` + `extra.currentSessions` |
+| **消息状态** | **事件驱动** | turn 开始/结束**立即** | `status`、`currentTask`、`extra.currentSessions` |
+
+**为什么必须分开**:心跳是为了让 App 知道「容器还活着」,频率越低服务器开销越小;
+消息状态是用户体验,必须实时。**把两者合并会导致要么心跳太频繁、要么状态太慢。**
+
+> lj-test 实测:抖动在**进程启动时随机一次并保持**,不是每轮都变。
+
+### 3.2 `currentSessions` 结构
+
+放入 `extra` 字段(**不能作顶层字段,否则 HTTP 400**):
+
+```json
+{
+  "currentSessions": [
+    {
+      "sessionKey": "$approot_xxx 或 !roomId",
+      "userId": "@用户:fmode.cn",
+      "status": "running | idle",
+      "task": "本轮提问首行,60 字内",
+      "turnStartAt": 1794405380428,
+      "updatedAt": 1794405433842
+    }
+  ]
+}
+```
+
+| 字段 | 来源 | 缺失后果 |
+|---|---|---|
+| `sessionKey` | thread_id 优先,退 chat_id | App 匹配不上,状态永远不显示 |
+| `userId` | `state.db.sessions.user_id` | 多人共用时 A 跑步影响 B |
+| `task` | 本轮用户消息首行 | 顶栏只显示「正在处理」 |
+| `updatedAt` | 写入时 epoch ms | App 无法判断是否过期 |
+
+### 3.3 新增:`com.fmode.turn_status` 事件(App 已支持)
+
+**目的**:App 拉云函数是 15 秒轮询,容器上报再快,用户也要等最多 15 秒才看到「转圈停止」。
+让容器在 turn 结束时**直接往房间推一个 Matrix 事件**,App 收到即刷新。
+
+**事件 A(首选)自定义类型 `com.fmode.turn_status`**:
+
+```json
+{
+  "sessionKey": "$approot_xxx 或 !roomId",
+  "status": "idle"
+}
+```
+
+**事件 B(兜底)** 若 adapter 不允许自定义类型,用 `m.room.message` 携带:
+
+```json
+{
+  "msgtype": "m.text",
+  "body": "",
+  "fmode.turn_status": { "sessionKey": "$approot_xxx", "status": "idle" }
+}
+```
+
+**发送时机**:`post_llm_call` 钩子里,`_session_mark_idle()` **之后**。
+
+**要求**:
+- 失败**只记日志**,不影响 turn 收尾与上报
+- 与 ✅ reaction **完全独立**,不得互相替代
+- `sessionKey` 用与 `currentSessions` 相同的值
+
+> ⚠️ App 侧已实现监听(`matrix-client.service.ts::onTurnStatus`),
+> **监听所有房间**,不限于当前打开的会话。
+> 但容器侧改动**必须重启 gateway 才生效**(见 §2.3)。
+
+### 3.4 `_thread_root()` 必须有兜底
+
+文件登记要求 `threadRoot` **必填**(`if (!threadRoot) return _fail(400, 'threadRoot required')`)。
+
+lj-test 的实现从 `state.db` 查 `thread_id`,**取不到就返回 None → 跳过登记 → 文件丢失**。
+
+```python
+def _thread_root(sid=None):
+    ...
+    row = con.execute("SELECT thread_id FROM sessions WHERE id=?", (sid,)).fetchone()
+    if not row or not row[0]:
+        return None          # ← 这里会让文件永远无法归属到会话
+    return str(row[0])
+```
+
+**建议改为**:
+
+```python
+    thread_id = row[0] if row else None
+    if not thread_id:
+        # 一房间一会话下,房间号即会话标识,可安全兜底
+        return _chat_id_of(sid) or None
+    return str(thread_id)
+```
+
+---
+
+## 四、文件与成果交付链路
+
+```
+容器产出文件
+   ↓
+① 调云函数 AlP56LCKFm 取 OBS 预签名 URL
+   ↓
+② PUT 上传到 OBS
+   ↓
+③ 调云函数 THXAOGKiPK { action: "registerFile", threadRoot, ... } 登记台账
+   ↓
+④ 正文末尾追加「已上云(可直接下载)」+ 链接(由 plugin-deliverable-inject 完成)
+   ↓
+App 右侧「成果」栏按会话显示
+```
+
+**登记参数**(`registerFile`):
+
+| 参数 | 必填 | 说明 |
+|---|---|---|
+| `threadRoot` | ✅ | **缺失直接 400** |
+| `agentId` | ✅ | 容器标识 |
+| `url` | ✅ | 公网可访问地址(`s3.fmode.cn` / `file-cloud.fmode.cn`) |
+| `fileName` / `fileSize` / `mimeType` | 建议 | 展示用 |
+| `uploadedBy` | 建议 | `agent`(容器产出)/ `user`(用户上传) |
+| `roomId` | 建议 | 仅作附带字段,**归属用的是 threadRoot** |
+
+**正文链接格式**(App 用正则识别):
+
+```
+(正文末尾空一行)
+**已上云(可直接下载)**
+- https://s3.fmode.cn/...
+```
+
+> 🔴 `threadRoot` 是**唯一归属键**,`roomId` 只是附带信息。
+> 用房间号去查 `listBySession` 会得到 0 条(实测)。
+
+---
+
+## 五、多人共用:三个真实反面案例
+
+### 案例 A · 往 `.env` 加白名单不生效
+
+| 项 | 内容 |
+|---|---|
+| 现象 | 同事能进会话、能发消息,但容器不回复 |
+| 日志 | `WARNING gateway.run: Unauthorized user (ignored): Dropped a message from unrecognized matrix user (@xxx:fmode.cn)` |
+| 根因 1 | 改了 `.env` 但**没重启** gateway |
+| 根因 2 | 进程里的 `MATRIX_ALLOWED_USERS` 是空串 → 走了 ALLOW_ALL 分支,白名单是「死配置」 |
+| 解法 | 重启后**必须**回读 `/proc/<pid>/environ` 确认 |
+
+### 案例 B · s6 注入压过 `.env`(容器内无解)
+
+| 项 | 内容 |
+|---|---|
+| 现象 | 改了 `.env`、重启了,**仍然**被拒 |
+| 根因 | 平台在容器启动时把 `MATRIX_ALLOWED_USERS` 注入到 `/run/s6/container_environment/`,**优先级高于 `.env`** |
+| 证据 | 该文件 `root:root`,属主是 root,容器用户**无权删除** |
+| 已试失败的解法 | ① `.env` 追加用户 ② `ALLOW_ALL=true` ③ `.env` 置空串 ④ `.env` 整行删除 ⑤ 删 s6 文件 |
+| 解法 | **只能平台侧处理**:删除 `/run/s6/container_environment/MATRIX_ALLOWED_USERS` 后重启 |
+| 判断方法 | ```ls -l /run/s6/container_environment/ \| grep -i matrix``` 有输出即中招 |
+
+> 实测对照:`lj-test`(2026-10-17 建)**没有**这个注入;
+> `agent-lj`(2026-10-21 建)**有**。推测平台在 10-21 后改了创建流程。
+
+### 案例 C · ALLOW_ALL 开了但白名单非空
+
+| 项 | 内容 |
+|---|---|
+| 现象 | 配了 `GATEWAY_ALLOW_ALL_USERS=true`,陌生用户仍被拒 |
+| 根因 | `MATRIX_ALLOWED_USERS` 有值,判定短路在第一层,根本走不到 ALLOW_ALL |
+| 解法 | **把白名单置空**(不是清成空串,是确保进程里读到空) |
+
+### 5.1 多人共用的正确配置
+
+```bash
+GATEWAY_ALLOW_ALL_USERS=true
+MATRIX_ALLOWED_USERS=          # ← 留空
+```
+
+**效果**:主人、同公司成员、未来新成员、甚至其他公司的人都能用。
+**代价**:跨公司用户只要知道容器 mxid 也能用(用户已明确接受)。
+
+### 5.2 多房间与多人共用
+
+- 每个用户 × 每个容器 = **一个 DM 房间**(provision 负责建)
+- 容器 **单条 sync 连接**监听全部房间,**多房间开销几乎为零**
+- 真正的瓶颈是**并发 turn**:N 人同时问 = N 倍 token
+
+---
+
+## 五之二、插件移植:从 lj-test 复制整套能力
+
+> 🔴 **前置判断(2026-11-12 实测,务必先做)**
+>
+> 新容器很可能**根本没有装任何 Fmode 插件**。实测 `agent-node-app-lj-test-v1`:
+>
+> ```
+> /opt/data/plugins/                       → 目录不存在
+> plugin-active-reporter                   → 全盘零结果
+> currentSessions / turnStartAt / sessionKey → 全盘零结果
+> com.fmode.turn_status                    → 零结果
+> ```
+>
+> 这种容器只有 Hermes 原生 `platforms/matrix` 能力:能收发消息,
+> 但**不上报状态、没有会话表、没有文件交付**。
+> 此时要做的不是「改配置」,而是**整套插件移植**。
+
+### 5.2.1 lj-test 的插件清单(实测导出)
+
+| 插件 | 文件 | 必需 | 作用 |
+|---|---|---|---|
+| `plugin-active-reporter` | `__init__.py`(32,277 B)+ `plugin.yaml` | ✅ **核心** | 状态上报、`currentSessions`、turn_status |
+| `plugin-deliverable-inject` | `__init__.py`(5,410 B)+ `plugin.yaml` | ✅ | 文件交付链接注入正文 |
+| `plugin-turn-end` | `__init__.py`(13,202 B) | ⭕ 可选 | 回合结束信号(lj-test 中处于 `disabled`) |
+| `plugin-media-text-merge` | `__init__.py`(9,549 B) | ⭕ 可选 | 图文合并(lj-test 中未启用) |
+| `plugin-fmode/plugin` | 整套目录 | ⭕ 可选 | 通用能力(含 `poor_guard.py` 钩子) |
+| `plugin-tips-cn` | 整套目录 | ⭕ 可选 | 中文提示词增强 |
+| `plugin-topup-guard` | `__init__.py` | ⭕ 可选 | 充值保护 |
+
+> ⚠️ `plugin-active-reporter` 目录里还有多个 `.bak-*` 备份文件(累计 100+ KB),
+> **复制时排除**,只取 `__init__.py` 与 `plugin.yaml`。
+
+### 5.2.2 `config.yaml` 的注册方式
+
+插件**不是放进目录就生效**,必须在 `config.yaml` 的 `plugins.enabled` 里登记。
+lj-test 的实际配置(节选):
+
+```yaml
+plugins:
+  enabled:
+    - plugin-active-reporter
+    - plugin-deliverable-inject
+    - plugin-fmode/plugin
+    - plugin-tips-cn
+    - plugin-topup-guard
+    - wecom-platform
+  entries:
+    plugin-fmode/plugin:
+      allow_tool_override: true
+    plugin-deliverable-inject:
+      allow_tool_override: false
+  disabled:
+    - plugin-turn-end
+hooks:
+  on_session_end:
+    - command: python3 /opt/data/plugins/plugin-fmode/plugin/hooks/poor_guard.py
+      timeout: 30
+```
+
+**最小可用组合**:只启用 `plugin-active-reporter` + `plugin-deliverable-inject`。
+
+### 5.2.3 移植步骤
+
+```bash
+# 1. 在源容器(lj-test)打包(排除备份)
+cd /opt/data/plugins
+tar czf /tmp/fmode-plugins.tgz \
+  --exclude='*.bak-*' \
+  plugin-active-reporter plugin-deliverable-inject
+
+# 2. 传到目标容器(用容器间可用的通道,或经 OBS)
+#    目标容器落地后:
+cd /opt/data
+tar xzf fmode-plugins.tgz -C /opt/data/plugins/   # 确保 plugins 目录已存在
+ls -l /opt/data/plugins/plugin-active-reporter/
+
+# 3. 编辑 config.yaml,把插件名加进 plugins.enabled
+#    步骤见 §5.2.2
+
+# 4. 重启(必须!见 §2.3)
+s6-svc -r /run/service/gateway-default
+
+# 5. 验证插件真的加载了
+PID=$(pgrep -f "gateway run" | tail -1)
+ps -o lstart= -p $PID                      # 进程启动时间应晚于文件 mtime
+grep -iE "registered:|plugin-active-reporter" /opt/data/logs/gateway.log | tail -10
+```
+
+> 🔴 **第 5 步是关键**:日志里必须出现 `registered: plugin-active-reporter`。
+> 没有这行 = 没加载成功,后面所有验证都不必做。
+
+### 5.2.4 依赖与环境变量
+
+`plugin-active-reporter` 需要:
+
+| 依赖 | 说明 |
+|---|---|
+| `FMODE_SESSION_TOKEN` | 调云函数 `xlosi3m46q` 的身份(主人 token) |
+| `AGENT_ID` | 容器自己的 agentId |
+| `MATRIX_ACCESS_TOKEN` / `MATRIX_HOMESERVER` | 推 `turn_status` 事件用(新代码会从 `.env` 读) |
+| Python 标准库 | `sqlite3` / `urllib` / `json`(**无需 pip 安装**) |
+
+**在目标容器 `.env` 里补齐**:
+
+```bash
+FMODE_SESSION_TOKEN=r:xxxx          # 主人的平台 token
+AGENT_ID=agent-node-app-lj-test-v1  # 目标容器自己的 agentId
+```
+
+---
+
+## 五之三、🔴 先判形态:五类容器,走五条不同的路
+
+> **这是本文最重要的一节。** 接入前**必须先判定容器属于哪一类**,
+> 否则会按错误的路径操作,白折腾一轮(2026-11-12 在 v1 上实际踩过)。
+
+| 形态 | 特征(怎么判) | 走哪条路 |
+|---|---|---|
+| **A · 完整**(lj-test) | 有插件、`capability_check decision=allow`、上报正常 | 只核对配置 |
+| **B · 有插件无授权** | 插件名在日志里出现,但 `decision=deny` | **补 `granted_capabilities`**(见 §六 阶段 2 场景 0.5) |
+| **C · 无插件** | `ls /opt/data/plugins/` 报 `No such file or directory`;全盘搜 `plugin-active-reporter` 零结果 | **插件移植**(见 §五之二) |
+| **D · 有插件但版本旧** | 有 `plugin-active-reporter`,但 grep 不到 `com.fmode.turn_status` / `userId` | 覆盖为新版(重新解包) |
+| **E · 准入被 s6 锁死** | `ls /run/s6/container_environment/ \| grep MATRIX_ALLOWED` 有输出 | **只能平台侧处理**(见 §5.2) |
+
+### 判定命令(一次跑完,30 秒)
+
+```bash
+echo "=== 形态判定 ==="
+# C 的判断:插件目录
+ls -d /opt/data/plugins 2>/dev/null && echo "有 plugins 目录" || echo "C: 无 plugins 目录"
+find /opt/data/plugins -maxdepth 1 -name 'plugin-*' -type d 2>/dev/null | head -5
+
+# B 的判断:能力授权
+grep -h "capability_check" /opt/data/logs/*.log 2>/dev/null | tail -4
+
+# D 的判断:插件版本
+grep -c "com.fmode.turn_status" /opt/data/plugins/plugin-active-reporter/__init__.py 2>/dev/null
+
+# E 的判断:s6 注入
+ls -l /run/s6/container_environment/ 2>/dev/null | grep -i matrix
+```
+
+**六类坑的对照(本文档全部覆盖)**:
+
+| 坑 | 现象 | 在哪解决 |
+|---|---|---|
+| K1 无插件 | 目录都不存在 | §五之三 形态 C |
+| **K2 缺能力授权** | `decision=deny`,插件装了等于没装 | §六 阶段 2 场景 0.5 |
+| K3 改配置没重启 | 授权不生效 | §六 阶段 2「四步法」第 ④ 步 |
+| K4 `yaml.safe_dump` 重写全文件 | 无关缩进被改 | §六 阶段 2「四步法」注意事项 |
+| K5 `s6-svc` 不在 PATH | `command not found` | 用 `/command/s6-svc` |
+| K6 系统 python 无 yaml | `ModuleNotFoundError` | 用 `/opt/hermes/.venv/bin/python3` |
+| **K7 自我升级污染对话** | 回复里出现「💾 修行成果」,甚至顶替答案 | `auxiliary.background_review.enabled: false` |
+| K8 无 `MATRIX_ACCESS_TOKEN` | 推 `turn_status` 可能失败 | §六 阶段 2 场景 0.6 |
+| K9 插件包 URL 拿不到 | 回复里被截断 | 让对方传固定路径,或用户浏览器取 |
+
+---
+
+## 六、完整提示词(交给新容器执行)
+
+> **使用方法**:把下面四段**按顺序**发给新容器,每段等它**交齐证据**后再发下一段。
+> 不要在阶段 1 之前就让它改代码。
+
+---
+
+### 阶段 1 · 自查(只读,禁止修改)
+
+```text
+你现在要对「接入 Fmode App」做一次**只读自查**。
+
+🔴 硬性约束:
+- 只读:不许改任何文件、不许重启、不许写库、不许上报
+- 每条都必须贴**实际命令 + 原始输出**,不许写「应该」「大概」「可能」
+- 查不到就明确写「不存在 / 没在跑 / 无法确认」,不许推测
+
+请逐条回答,最后汇总成一张表:
+
+【A. 进程与代码】
+1. gateway 进程在跑吗?PID 多少?启动时间是什么?
+2. 插件目录 /opt/data/plugins/ 下有哪些插件?
+3. plugin-active-reporter/__init__.py 的修改时间(mtime)是什么?
+   → 对比 A1 的进程启动时间:**文件比进程新就说明改动没生效**
+
+【A0. 🔴 先判断:你装了 Fmode 插件吗?(决定后面走哪条路)】
+   ls -la /opt/data/plugins/ 2>&1 | head -20
+   find / -name "plugin-active-reporter" -maxdepth 6 2>/dev/null | head -3
+   grep -rn "currentSessions" /opt/data 2>/dev/null | head -3
+   grep -rn "com.fmode.turn_status" /opt/data 2>/dev/null | head -3
+
+   明确回答:
+   ① /opt/data/plugins/ 目录存在吗?
+   ② 有没有 plugin-active-reporter / plugin-deliverable-inject?
+   ③ 全盘搜索 currentSessions、turn_status 有没有命中?
+
+   → 若三条都是「不存在/零结果」:你**没有装 Fmode 插件**,
+     后续不是"改配置"的问题,而要**从 lj-test 移植整套插件**(见文档 §五之二)。
+     请在报告里明确标注「类型:无插件容器,需移植」。
+   → 若有插件:继续往下查具体配置差异。
+
+【B. Matrix 配置】
+4. 回读进程环境:
+   PID=$(pgrep -f "gateway run" | tail -1)
+   tr '\0' '\n' < /proc/$PID/environ | grep -E "MATRIX_|GATEWAY_ALLOW"
+   明确回答:MATRIX_ALLOWED_USERS 是空 / 有值 / 不存在?ALLOW_ALL 是什么值?
+5. /opt/data/.env 里这几行的值(值可脱敏):
+   MATRIX_HOMESERVER / MATRIX_USER_ID / MATRIX_REACTIONS /
+   MATRIX_ALLOWED_USERS / GATEWAY_ALLOW_ALL_USERS
+6. ls -l /run/s6/container_environment/ | grep -i matrix
+   → 有 MATRIX_ALLOWED_USERS 吗?(有 = 平台注入,容器内改不动)
+
+【C. 上报机制】
+7. 心跳间隔配置是多少秒?有无抖动?代码在哪一行?
+8. turn 开始/结束的上报是**事件驱动**还是等心跳?
+   给出触发函数名 + 文件 + 行号
+9. extra.currentSessions 里每条记录的 6 个字段(sessionKey/userId/status/task/
+   turnStartAt/updatedAt)是否齐全?缺哪个?
+10. 有没有发送 com.fmode.turn_status 事件?代码在哪?
+
+【D. 文件交付】
+11. plugin-deliverable-inject 存在吗?
+12. _thread_root() 函数在哪?取不到 thread 时返回什么?
+13. 最近一次成功登记文件是什么时候?日志贴出来
+
+【E. 准入】
+14. 日志里最近 24 小时有没有 "Unauthorized user"?出现几次?涉及哪些 mxid?
+
+输出格式(必须用表格):
+| 检查项 | 当前值 | 达标? | 依据(命令输出) |
+达标 = ✅ / 未达标 = ❌ / 无法确认 = ❓
+```
+
+**通过条件**:14 条全部有实际输出,表格完整,没有「应该/大概」。
+
+---
+
+### 阶段 2 · 按差距修改
+
+```text
+基于阶段 1 的自查表,**只修改标为 ❌ 的项**。✅ 的不要动。
+
+🔴 硬性约束:
+- 改之前先备份:cp <文件> <文件>.bak-$(date +%Y%m%d-%H%M%S)
+- /opt/hermes 是只读的,改不了;只能改 /opt/data 下的
+- 每改一项,说明:改了哪个文件、哪一行、改前改后是什么
+- 不许改与目标无关的代码
+
+需要修改的场景与对应做法:
+
+【场景 0】🔴 阶段 1 判定为「无插件容器」(最常见)
+  你没有 plugin-active-reporter,状态上报整条链路都不存在。
+
+  **做法:从 lj-test 移植整套插件**(详见文档 §五之二)
+
+  ① 让 lj-test 打包并导出(把下面这段发给 lj-test):
+     cd /opt/data/plugins && tar czf /tmp/fmode-plugins.tgz \
+       --exclude='*.bak-*' plugin-active-reporter plugin-deliverable-inject
+     然后把 tgz 用 OBS 或容器间通道传到本容器。
+
+  ② 本容器解包:
+     mkdir -p /opt/data/plugins
+     tar xzf <路径>/fmode-plugins.tgz -C /opt/data/plugins/
+     ls -l /opt/data/plugins/plugin-active-reporter/__init__.py
+
+  ③ 在 config.yaml 的 plugins.enabled 里登记:
+       - plugin-active-reporter
+       - plugin-deliverable-inject
+
+  ④ 补齐 .env 环境变量:
+       FMODE_SESSION_TOKEN=r:xxx            # 主人的平台 token
+       AGENT_ID=<本容器自己的 agentId>
+
+  ⑤ 重启并确认加载:
+     s6-svc -r /run/service/gateway-default
+     sleep 10
+     grep -iE "registered:.*(reporter|deliverable)" /opt/data/logs/gateway.log | tail -5
+     → **必须看到 registered 行**,否则插件没加载
+
+【场景 0.5】🔴 插件已装但被拒绝授权(v1 实测踩过,最容易漏)
+  现象:日志里能查到插件名,但每次 turn 都被拒:
+    capability_check plugin=plugin-active-reporter capability=tools.override decision=deny
+    evidence=not granted
+  → **插件装了等于没装**,状态照样上报不出去。
+
+  根因:Hermes 是「默认拒绝」设计,必须在 config.yaml 显式授权。
+  `plugins.enabled` 只负责「加载」,`plugins.entries.<名>.granted_capabilities` 才负责「放权」。
+
+  修法(config.yaml):
+    plugins:
+      entries:
+        plugin-active-reporter:
+          granted_capabilities:
+          - tools.override
+        plugin-deliverable-inject:
+          granted_capabilities:
+          - tools.override
+
+【场景 0.6】🔴 关闭 background_review(解决「自我升级日志污染对话」)
+  现象(用户实测截图):问「把完整下载地址贴出来」,
+  回复却是「这一轮的修行成果:🔧 给「multi-artifact-delivery」上了个补丁」,
+  用户的问题完全没被回答;有时还重复发送上一轮发过的文件。
+
+  根因:Hermes 每轮结束会跑 background review,
+  通过 `background_review_callback` 把摘要**发到对话里**
+  (`/opt/hermes/agent/background_review.py:1148-1153`)。
+
+  修法(config.yaml,**官方开关**,不改只读源码):
+    auxiliary:
+      background_review:
+        enabled: false
+  关掉后复盘不会启动 → 那条 💾 消息从源头就不产生。
+
+  验证:发一句闲聊,回复里**不得出现** 💾 / 修行 / 经验值 / 复盘。
+
+【config.yaml 修改四步法】(上面几个场景都按这个执行)
+
+  🔴 四个必踩的坑,全在这四步里避掉:
+
+  ① 备份
+     cp /opt/data/config.yaml /opt/data/config.yaml.bak-$(date +%Y%m%d-%H%M%S)
+
+  ② 用 **venv 的 python** 改(系统 python 没有 yaml 模块 → ModuleNotFoundError)
+     /opt/hermes/.venv/bin/python3 - <<'PY'
+     import io, yaml
+     p = '/opt/data/config.yaml'
+     d = yaml.safe_load(io.open(p, encoding='utf-8'))
+
+     # --- 按实际需要选一段 ---
+     # (a) 插件登记
+     pl = d.setdefault('plugins', {})
+     en = pl.setdefault('enabled', [])
+     for n in ['plugin-active-reporter', 'plugin-deliverable-inject']:
+         if n not in en: en.append(n)
+     # (b) 能力授权(关键!)
+     ent = pl.setdefault('entries', {})
+     for n in ['plugin-active-reporter', 'plugin-deliverable-inject']:
+         gc = ent.setdefault(n, {}).setdefault('granted_capabilities', [])
+         if 'tools.override' not in gc: gc.append('tools.override')
+     # (c) 关复盘
+     d.setdefault('auxiliary', {}).setdefault('background_review', {})['enabled'] = False
+
+     io.open(p, 'w', encoding='utf-8').write(
+         yaml.safe_dump(d, allow_unicode=True, sort_keys=False, default_flow_style=False))
+     PY
+
+  ③ 校验 + 核对 diff
+     /opt/hermes/.venv/bin/python3 -c "import yaml,io;d=yaml.safe_load(io.open('/opt/data/config.yaml',encoding='utf-8'));print('top keys',len(d));print('entries',d.get('plugins',{}).get('entries'));print('bg',d.get('auxiliary',{}).get('background_review'))"
+     diff /opt/data/config.yaml.bak-* /opt/data/config.yaml | head -40
+
+     ⚠️ **K4 注意**:`yaml.safe_dump` 会**重写整个文件**,
+        可能把 `custom_providers` 之类的缩进也规范化掉。
+        结构等价但格式变了 —— 用 diff 确认「只出现预期的新增行」。
+        若不能接受,改用备份还原后**手工**加那几行。
+
+  ④ 重启(**必须**,改 config 不重启等于没改)
+     🔴 **K5**:`s6-svc` 不在 PATH,必须用绝对路径
+     /command/s6-svc -r /run/service/gateway-default
+     sleep 12
+     pgrep -af "gateway run"
+
+【场景 1】MATRIX_ALLOWED_USERS 有值(多人共用打不通)
+  目标:GATEWAY_ALLOW_ALL_USERS=true 且 MATRIX_ALLOWED_USERS 为空
+  步骤:
+    sed -i 's|^MATRIX_ALLOWED_USERS=.*|MATRIX_ALLOWED_USERS=|' /opt/data/.env
+    # 先尝试替换,没有这一行就追加(用 if 判断,避免行首的 OR 运算符)
+    if grep -q "^GATEWAY_ALLOW_ALL_USERS=" /opt/data/.env; then
+      sed -i 's|^GATEWAY_ALLOW_ALL_USERS=.*|GATEWAY_ALLOW_ALL_USERS=true|' /opt/data/.env
+    else
+      echo "GATEWAY_ALLOW_ALL_USERS=true" >> /opt/data/.env
+    fi
+  ⚠️ 若 /run/s6/container_environment/MATRIX_ALLOWED_USERS 存在:
+     容器内无解,**停止**,报告「需要平台侧删除该文件」
+
+【场景 2】turn 状态不是事件驱动(状态更新慢)
+  找到 pre_llm_call / post_llm_call 钩子,确认里面有立即发 HTTP 的调用
+  若没有,补上;若只有心跳,说明上报机制需要改
+
+【场景 3】currentSessions 字段不全
+  补齐 6 字段,见文档 §3.2
+
+【场景 4】没有 turn_status 事件
+  在 post_llm_call 里、_session_mark_idle() 之后,加:
+    try:
+        _push_turn_status(session_key, "idle")
+    except Exception as e:
+        log("turn_status failed: %s" % e)   # 失败不影响主流程
+  事件类型 com.fmode.turn_status,内容 {"sessionKey": key, "status": "idle"}
+
+【场景 5】_thread_root 取不到就返回 None(文件会丢)
+  改为取不到时退回 _chat_id_of(sid),见文档 §3.4
+
+【改完之后必须做(缺一不可)】
+  ① 重启:s6-svc -r /run/service/gateway-default
+  ② 等 10 秒,确认进程起来了:pgrep -f "gateway run"
+  ③ 回读新进程环境:
+     PID=$(pgrep -f "gateway run" | tail -1)
+     ps -o lstart= -p $PID
+     tr '\0' '\n' < /proc/$PID/environ | grep -E "MATRIX_ALLOWED|ALLOW_ALL"
+  ④ 确认 A1 的「文件比进程新」问题已消除(进程启动时间现在应晚于文件 mtime)
+
+贴出以上四步的原始输出。不许只写「已重启」。
+```
+
+**通过条件**:每项修改都有 diff 说明,四步验证都有原始输出。
+
+---
+
+### 阶段 3 · 自测审核
+
+```text
+现在做**自测**。每项都要贴实测输出,全绿才能进入联调。
+
+🔴 不许说「应该成功了」「理论上没问题」。
+
+【测试 1】DNS 解析(影响每轮响应速度)
+python3 -c "import socket,time;t=time.time();socket.getaddrinfo('api.fmode.cn',443,socket.AF_UNSPEC);print('解析耗时=%.0f ms' % ((time.time()-t)*1000))"
+通过标准:< 100 ms(> 1000ms 说明有 DNS 卡顿,需先修)
+
+【测试 2】准入门槛
+回读进程环境,确认 MATRIX_ALLOWED_USERS 为空 且 ALLOW_ALL=true
+再用一个**不存在的** mxid 验证逻辑:
+grep -n "_principal_authorized" -A 20 <authz 文件>
+贴出判定分支代码,说明「陌生用户会走到哪一支」
+
+【测试 3】✅ reaction
+让它回一条消息,然后用 /sync 或 /messages 读该房间事件
+通过标准:能看到 m.reaction,key 为 ✅
+
+【测试 4】状态上报时效(最关键)
+让一个外部脚本每秒轮询 xlosi3m46q 读你的 currentSessions,
+同时发给容器一条消息。
+通过标准:status 从 none → running → idle 的整个过程在 **10 秒内**完成
+(lj-test 基线是 5s 报 running、7s 报 idle)
+
+【测试 5】心跳频率
+观察 10 分钟(无 turn 活动)
+通过标准:上报请求 ≤ 1 次
+
+【测试 6】currentSessions 字段
+发一条消息,回读 extra.currentSessions
+通过标准:6 字段齐全,userId 是你自己的 mxid
+
+【测试 7】turn_status 事件
+发一条消息,读该房间事件
+通过标准:能看到 com.fmode.turn_status,内容含 sessionKey 和 status=idle
+若看不到 → 回到阶段 2 检查「是否真的重启了」
+
+【测试 8】文件交付
+让它产出一个文件
+通过标准:正文末尾有「已上云(可直接下载)」+ https 链接
+且 listBySession(threadRoot=你的会话id) 能查到这条
+
+【测试 9】🔴 插件能力授权(v1 实测踩过:装了不生效)
+grep -h "capability_check" /opt/data/logs/*.log | tail -10
+通过标准:
+  - 最近一次 capability_check 必须是 `decision=allow`,
+    且 evidence=`granted_capabilities`
+    (🔴 日志里的关键字是 **allow**,不是 grant —— 源码写的是
+      "allow" if allowed else "deny",按 grant 去 grep 会永远是 0)
+  - **只判定最近一次**:历史日志里会累计大量重启前的 `deny`,
+    不要用 `grep -c deny` 的总数判断(v1 实测:185 条 deny 全是重启前的历史)
+若最近一次仍是 deny → 回到阶段 2【场景 0.5】补 granted_capabilities,
+  并确认`/command/s6-svc -r` 真的执行了(改配置不重启不生效)
+
+【测试 10】🔴 对话纯净度(不许自我升级日志污染回答)
+发一句闲聊,例如:「今天天气不错」
+通过标准:回复里**不得出现**以下任一:
+  💾、修行、经验值、复盘、自我进化、闭关、修为
+若出现 → 回到阶段 2【场景 0.6】关闭 background_review,并确认已重启
+
+输出格式:
+| 测试 | 实测输出 | 通过? |
+全部 ✅ 才能说「自测通过」。
+```
+
+**通过条件**:**10 项**全绿,每项附原始输出。有任一项 ❌ 则回到阶段 2。
+
+---
+
+### 阶段 4 · 联调实测(App 侧验收)
+
+```text
+容器侧自测已通过。现在进行 App 侧联调。
+
+🔴 这一阶段由 App 侧用固定脚本回读验证,**不采信容器自述**。
+
+请在 App 里(或让用户在 App 里)完成以下操作,同时自己贴出对应的容器侧证据:
+
+【联调 1】基本收发
+在 App 新建会话 → 发一句「请只回复两个字:收到」
+容器侧证据:贴出这一轮的 turn 开始/结束日志、上报日志
+
+【联调 2】左侧转圈时效
+观察 App 左侧会话列表的加载圆点
+通过标准:容器回复完成后 **几秒内**停止(不是 30 秒以上)
+容器侧证据:turn_status 事件的发送日志 + 事件 id
+
+【联调 3】会话隔离
+在 App 里两个不同会话先后发消息
+通过标准:只有发送的那条会话转圈,其他不转
+容器侧证据:currentSessions 里两条会话各自的 sessionKey 不同
+
+【联调 4】文件交付
+让它做一个文件并出示
+通过标准:App 右侧「成果」栏能看到,刷新后仍在
+
+【联调 5】多人共用(如适用)
+让另一个账号(同公司)发消息
+通过标准:容器正常回复,日志无 Unauthorized
+
+全部通过后,输出一份「接入完成报告」,包含:
+- 容器 mxid / agentId
+- 阶段 1 表格的最终版本(全部 ✅)
+- 阶段 3 八项测试结果
+- 本次修改的文件清单与备份路径
+```
+
+---
+
+## 七、故障速查
+
+| 你看到的 | 最可能原因 | 怎么确认 |
+|---|---|---|
+| 同事发消息不回复 | 准入被拦 | 日志找 `Unauthorized user` |
+| 改了配置没效果 | **没重启** / s6 注入覆盖 | 比对文件 mtime 与进程启动时间 |
+| App 左侧转圈停不下来 | 容器没发 `turn_status` 或没上报 idle | 读房间事件 + 轮询 `xlosi3m46q` |
+| 状态显示「正在执行」但实际空闲 | `currentSessions` 有残留 running | 回读 `extra`,看 updatedAt 多旧 |
+| 文件回复里没有下载链接 | `_thread_root()` 返回 None 跳过了登记 | grep 日志 `SKIP register` |
+| 所有历史会话同时转圈 | 容器级 status=running 被当会话级用 | App 侧问题,非容器 |
+| 回复很慢(7~9 秒) | DNS 卡顿 | §阶段3 测试 1 |
+| **插件装了没反应** | **缺 `granted_capabilities` 授权**(K2) | `grep capability_check` 看是否 `deny` |
+| **回复里出现「💾 修行成果」** | background_review 把摘要发进了对话(K7) | `auxiliary.background_review.enabled: false` |
+| **自我升级日志顶替了对问题的回答** | 同上(K7) | 同上,并检查是否重启 |
+| `s6-svc: command not found` | 不在 PATH(K5) | 用 `/command/s6-svc` |
+| `ModuleNotFoundError: No module named 'yaml'` | 系统 python 没有 yaml(K6) | 用 `/opt/hermes/.venv/bin/python3` |
+| 改 YAML 后无关缩进也变了 | `yaml.safe_dump` 重写全文(K4) | `diff` 核对;必要时手工改 |
+| 容器回复里的 URL 总是被截断 | 输出长度限制(K9) | 让它传到固定路径,或用户浏览器取 |
+
+---
+
+## 八、本次会话中发现的 lj-test 未决项
+
+| # | 项 | 状态 |
+|---|---|---|
+| 1 | `_push_turn_status()` 已写入代码,**但 gateway 尚未重启** | ⏳ 重启后生效 |
+| 2 | `currentSessions` 保留历史记录长达 13 小时(不清理) | ⚠️ 建议 `idle` 超 5 分钟清理 |
+| 3 | `_thread_root()` 取不到 thread 时返回 None | ⚠️ 建议加房间号兜底 |
+
+---
+
+*本文所有数据为 2026-11-12 实测,非推断。*

+ 54 - 13
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -6,6 +6,7 @@ import { LiveService } from '../../../core/live.service';
 import { NewSessionModalService } from './new-session-modal.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { GroupChatService } from '../../../core/group-chat.service';
+import { ChatService } from '../../../core/chat.service';
 import { Agent, SemanticTone } from '../../../core/api/models';
 import { resolveAgentState } from '../../../core/agent-state';
 
@@ -61,6 +62,8 @@ import { resolveAgentState } from '../../../core/agent-state';
             type="button"
             class="pick-row"
             [class.pick-row--checked]="isChecked(a)"
+            [disabled]="preparing()"
+            [attr.aria-busy]="preparing()"
             [attr.aria-selected]="mode() === 'group' ? isChecked(a) : false"
             (click)="mode() === 'group' ? toggleMember(a) : createIn(a.id)"
           >
@@ -91,6 +94,17 @@ import { resolveAgentState } from '../../../core/agent-state';
         }
       </div>
 
+      <!--
+        建房中(2026-11-12):一房间一会话要求先建好房间再进页面,
+        实测 0.6~0.7 秒。这里给出明确反馈,避免用户以为点了没反应。
+      -->
+      @if (preparing()) {
+        <p class="note note--muted" role="status" aria-live="polite">
+          <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
+          正在准备会话…(约 1 秒)
+        </p>
+      }
+
       @if (mode() === 'group') {
         @if (groupError()) {
           <p class="pick-empty" role="alert">{{ groupError() }}</p>
@@ -283,6 +297,9 @@ export class NewSessionModalComponent {
   private readonly device = inject(DeviceService);
   private readonly workspaces = inject(WorkspaceService);
   private readonly groupChat = inject(GroupChatService);
+  private readonly chat = inject(ChatService);
+  /** 正在建房(按钮 loading,防重复点击) */
+  protected readonly preparing = signal(false);
 
   /** 会话类型:单聊 / 群聊(2026-11-05) */
   protected readonly mode = signal<'dm' | 'group'>('dm');
@@ -341,20 +358,44 @@ export class NewSessionModalComponent {
       : { text: '在线', tone: 'success' };
   }
 
-  protected createIn(agentId: string): void {
+  /**
+   * 选好容器 → **先建成会话与专属房间,再跳进页面**(2026-11-12 用户要求)。
+   *
+   * ## 为什么改成 async
+   *
+   * 原先只跳转,首次发消息才建会话。但「一房间一会话」要求消息必须落进
+   * **该会话自己的房间**,而建房是网络操作(实测 598~717ms)。
+   * 若跳转后再异步建房,用户秒发消息会落进共享房间 ——
+   * 实测导致 5 条会话全在共享房间、左侧状态互相干扰(转圈 120 秒不停)。
+   *
+   * ## 现在的行为
+   *
+   * 点击 → 按钮显示「正在准备…」→ 建房 + 等容器进房(约 0.6~0.7 秒)
+   * → 跳转到该会话页。用户到达页面时房间已就绪,发送必定落对房间。
+   *
+   * 建房失败**不阻塞**:`startBlankWithRoom` 内部静默降级为共享房间,
+   * 用户仍能正常对话(只是状态隔离降级)。绝不能因为建房失败让人进不去。
+   */
+  protected async createIn(agentId: string): Promise<void> {
+    if (this.preparing()) return; // 防重复点击
     const target = this.service.workspaceId();
-    this.close();
-    /*
-     * 🔴 **不在这里创建会话**(2026-11-01 用户裁决:首次发消息才建会话)。
-     *
-     * 旧实现会 `sessions.create()` 一条空会话再跳转 —— 用户选完容器却没发消息,
-     * 就留下一条 0 条消息的「新对话」,反复几次列表就堆满空壳。
-     * 现在只是跳到「该容器、待建」的地址,输入第一条消息时才真正建会话。
-     *
-     * 工作区归属随查询参数带过去,由聊天页在会话建出来之后落地。
-     */
-    const qs = target ? `?ws=${encodeURIComponent(target)}` : '';
-    void this.router.navigateByUrl(`/${this.device.mode()}/chat/${agentId}/new${qs}`);
+    this.preparing.set(true);
+    try {
+      const s = await this.chat.startBlankWithRoom(agentId, target);
+      this.close();
+      if (!s) {
+        /* 容器未就绪(连接失败等):退回旧的「待建」地址,由聊天页给出错误提示 */
+        const qs = target ? `?ws=${encodeURIComponent(target)}` : '';
+        void this.router.navigateByUrl(`/${this.device.mode()}/chat/${agentId}/new${qs}`);
+        return;
+      }
+      /* 直接进这条会话(房间已就绪) */
+      void this.router.navigateByUrl(
+        `/${this.device.mode()}/chat/${agentId}/${encodeURIComponent(s.id)}`,
+      );
+    } finally {
+      this.preparing.set(false);
+    }
   }
 
   protected isChecked(a: Agent): boolean {

+ 68 - 35
src/app/components/domain/session-tree/session-tree.ts

@@ -701,12 +701,18 @@ export interface NewSessionRequest {
               群聊不挂单容器,不显示点。
             -->
             @if (s.kind !== 'group') {
+              <!--
+                🔴 转圈状态只求值一次(2026-11-12 实测「title 说正在执行但没转」)。
+                原来模板里调了 3 次 sessionRunningOf(),每次都会重算「时间相关」的判定,
+                两次调用之间可能落到不同的分支 → class 与 title 打架。
+              -->
+              @let spinning = sessionRunningOf(s);
               <i
                 class="sess-dot"
-                [class.spinning]="sessionRunningOf(s)"
+                [class.spinning]="spinning"
                 [attr.data-tone]="agentStateOf(s).tone"
-                [attr.title]="sessionRunningOf(s) ? '这条会话正在执行' : agentStateOf(s).text"
-                [attr.aria-label]="sessionRunningOf(s) ? '这条会话正在执行' : agentStateOf(s).text"
+                [attr.title]="spinning ? '这条会话正在执行' : agentStateOf(s).text"
+                [attr.aria-label]="spinning ? '这条会话正在执行' : agentStateOf(s).text"
                 aria-hidden="true"
               ></i>
             }
@@ -2503,49 +2509,76 @@ export class SessionTreeComponent implements OnDestroy {
    * 回落到原来的静态圆点,不会凭空转圈。
    */
   protected sessionRunningOf(s: ChatSession): boolean {
+    /* 时间推进触发器:见 `_timeTick` 的注释(不读它就永远不会重算转圈) */
+    this._timeTick();
     const agent = this.liveSvc.agentById(s.agentId);
     if (!agent) return false;
+
+    const now = Date.now();
+    const localStart = this.pulseSvc.sessionLocalStartAt(s.id);
+
     /*
-     * 🔴 当前打开的那条会话,若已收到 ✅ reaction → 立刻停止转圈
-     *    (2026-11-11 用户实测:正文输出完了,左侧还在转)。
+     * ═══════════════════════════════════════════════════════════════
+     * 左侧转圈的**唯一判据链**:全部是**会话级**信号,与容器级状态无关
+     * ═══════════════════════════════════════════════════════════════
+     *
+     * 用户明确要求(2026-11-12):
+     *   「左侧这个是会话级别的,和容器没有关系,仅和会话相关」
+     *
+     * 因此这里**绝不**读 `agent.status`(容器级 running/idle)——
+     * 容器同时在跑 3 条会话时,容器级 status 恒为 running,
+     * 一旦被左侧采用,就会出现「刷新后几十条历史会话一起转 10~15 秒」
+     * (用户实测截图)。
      *
-     * 回合卡片按 reaction 实时收尾,但左侧只认 `currentSessions`(15 秒轮询),
-     * 三处说法不一致。这里让左侧也直接用结束信号。
+     * 判定顺序(自上而下,先命中先生效):
      */
-    if (this.isCurrentSession(s) && this.chat.turnSettledLocally()) return false;
+    /* ① 容器已明确推送「本轮结束」→ 不转(最快,事件驱动) */
+    if (this.pulseSvc.sessionEndedAfterSend(s.id)) return false;
+
+    /* ② 容器会话表说这条**正在跑** → 转(唯一权威的"在运行"来源) */
+    if (
+      sessionRunning(agent.currentSessions, s.id, s.roomId, this.myMxIdOf(), now, localStart) ===
+      true
+    ) {
+      return true;
+    }
+
     /*
-     * 🔴 顺序很重要(2026-11-11 用户实测:左侧一直转圈不停)。
+     * ③ **不再**用「最近 25 秒有 agent 消息」来维持转圈(2026-11-12 用户裁决)。
      *
-     * **先问容器**:容器明确说这条会话 idle,就是最高优先级的事实 ——
-     * 本地「刚发出」标记不能盖过它。原实现把本地标记排在前面,
-     * 而清除标记的逻辑只写在 `describe()` 里(右侧用),
-     * 左侧根本不走那条路 → 标记一直留着 → 左侧永远转圈、右侧却已正常。
+     * 原实现:收到 agent 消息后还要再转 `REPLY_QUIET_MS`(25s) 才停。
+     * 用户实测:「容器回复完成了,右上角都显示在线,左侧还一直在转」——
+     * 这正是那 25 秒造成的,属于**猜结束**,不符合「容器说完成才结束」的要求。
      *
-     * 现在本地标记只作为「容器还没给结论」时的过渡,且带 TTL。
+     * 现在:只要容器没明确说 running(②)、也没推结束事件(①),就不转。
+     * 代价:首字发出前的那一瞬间可能不转 —— 由 ④ 的本地标记覆盖。
      */
-    const containerRunning = sessionRunning(
-      agent.currentSessions,
-      s.id,
-      // `s.roomId` 是退路:DM 通道 thread_id 常为 NULL,只能靠房间认
-      s.roomId,
-      this.myMxIdOf(),
-    );
-    if (containerRunning === false) {
-      /*
-       * 🔴 不在这里清本地标记(2026-11-11,NG0600)。
-       *
-       * 本方法由模板调用,处于渲染阶段 —— 写 signal 会抛
-       * `NG0600: Writing to signals is not allowed while Angular renders`,
-       * 直接中断渲染(这是「新建会话发消息卡住」的元凶之一)。
-       * 标记自带 60 秒 TTL,不需要主动清除。
-       */
-      return false;
-    }
-    if (containerRunning === true) return true;
-    /* 容器没给结论(没上报 / 不知道该查哪条)→ 才看本地「刚发出」标记 */
-    return this.pulseSvc.sessionLocalStartAt(s.id) !== undefined;
+
+    /* ④ 用户刚在这条发出消息、容器还没给结论 → 转(过渡态,带 TTL) */
+    return localStart !== undefined;
   }
 
+  /**
+   * 该行的容器状态点该显示什么(含转圈)。
+   *
+   * 单独抽出来是因为模板里要用到两次(`spinning` 与 `title`),
+   * 之前两处分别求值出现过「title 说正在执行、class 没转」的不一致(实测)。
+   */
+  protected dotTitleOf(s: ChatSession): string {
+    if (this.sessionRunningOf(s)) return '这条会话正在执行';
+    return this.agentStateOf(s).text;
+  }
+
+  /**
+   * 让模板在时间流逝后重新求值(2026-11-12)。
+   *
+   * `sessionIsWriting()` / `sessionLocalStartAt()` 只读**时刻**,不读信号;
+   * 没有这行依赖,Angular 不会在「消息停了」之后重算 `[class.spinning]`,
+   * 转圈会一直挂到下一次容器上报才停。
+   * `live.tick()` 每轮轮询递增,正好可以当时间推进的触发器。
+   */
+  protected readonly _timeTick = this.liveSvc.tick;
+
   /** 这条会话是不是用户当前打开的那条 */
   private isCurrentSession(s: ChatSession): boolean {
     return this.chat.sessionId() === s.id;

+ 68 - 0
src/app/core/agent-state.spec.ts

@@ -305,6 +305,33 @@ describe('sessionStateOf / sessionRunning(会话级状态)', () => {
     expect(sessionRunning(SESSIONS, null)).toBeUndefined();
   });
 
+  /*
+   * 🔴 陈旧的 idle 不能盖过「用户刚发出」(2026-11-12 用户实测)。
+   *
+   * 现象:左侧对话条目「点开就停,不点开就转」。
+   * 根因之一:容器 `currentSessions` 里留着上一轮收尾时写的 `idle`
+   * (实测 turnStartAt→updatedAt 仅差 1 秒),它的 updatedAt **早于**本轮提问。
+   * 若直接采信这条 idle,刚发出的会话一下都不转。
+   */
+  describe('陈旧 idle 与本地「刚发出」的先后', () => {
+    const NOW = 1_800_000_000_000;
+    const staleIdle = [
+      { sessionKey: APP_SID, status: 'idle' as const, updatedAt: NOW - 30_000 },
+    ];
+
+    it('idle 早于本地发出时刻 → 不采信(返回 undefined)', () => {
+      expect(sessionRunning(staleIdle, APP_SID, null, null, NOW, NOW - 5_000)).toBeUndefined();
+    });
+
+    it('idle 晚于本地发出时刻 → 正常判空闲', () => {
+      expect(sessionRunning(staleIdle, APP_SID, null, null, NOW, NOW - 60_000)).toBeFalse();
+    });
+
+    it('不传本地时刻 → 保持原行为(直接判空闲)', () => {
+      expect(sessionRunning(staleIdle, APP_SID, null, null, NOW)).toBeFalse();
+    });
+  });
+
   /*
    * 🔴 roomId 退路(2026-11-11 实测加了这条)。
    *
@@ -338,6 +365,47 @@ describe('sessionStateOf / sessionRunning(会话级状态)', () => {
       expect(sessionStateOf(mixed, APP_SID, '!roomA')?.status).toBe('running');
     });
   });
+
+  /*
+   * 🔴 房间号**不得**子串匹配(2026-11-12 用户实测事故)。
+   *
+   * 老实现用 `sessionKey.includes(roomId)`:51 条会话共享同一个 `!roomId` 时,
+   * 容器为**某一条**上报 running,其余所有会话都会命中 → 「明明没回复也在转」。
+   *
+   * 这些用例锁住新判据,防止有人图省事再改回子串匹配。
+   */
+  describe('房间号匹配不得传染同房间的其他会话', () => {
+    it('🔴 `!roomA:别的会话` 不得命中 !roomA 的会话', () => {
+      const other = [
+        { sessionKey: '!roomA:thread_of_another_session', status: 'running' as const },
+      ];
+      expect(sessionStateOf(other, APP_SID, '!roomA')).toBeUndefined();
+    });
+
+    it('🔴 前缀相似的房间号不得命中(!roomAxxx ≠ !roomA)', () => {
+      const other = [{ sessionKey: '!roomAxxx', status: 'running' as const }];
+      expect(sessionStateOf(other, APP_SID, '!roomA')).toBeUndefined();
+    });
+
+    it('纯房间号上报仍可命中(一房间一会话的典型形态)', () => {
+      const pure = [{ sessionKey: '!roomA', status: 'running' as const }];
+      expect(sessionStateOf(pure, APP_SID, '!roomA')?.status).toBe('running');
+    });
+
+    it('带结构前缀的房间号可命中(容器 agent:main:matrix:dm:!roomA)', () => {
+      const prefixed = [{ sessionKey: 'agent:main:matrix:dm:!roomA', status: 'running' as const }];
+      expect(sessionStateOf(prefixed, APP_SID, '!roomA')?.status).toBe('running');
+    });
+
+    it('🔥 同房间两条记录:只命中精确的那条,不串到另一条', () => {
+      const two = [
+        { sessionKey: '!roomA:other', status: 'running' as const },
+        { sessionKey: '!roomA', status: 'idle' as const },
+      ];
+      // 本会话 → 命中 idle 那条,而**不是** other 的 running
+      expect(sessionStateOf(two, APP_SID, '!roomA')?.status).toBe('idle');
+    });
+  });
 });
 
 /*

+ 72 - 4
src/app/core/agent-state.ts

@@ -404,10 +404,32 @@ export function sessionStateOf(
     (s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId),
   );
   if (byThread) return byThread;
-
-  /* ② 退路:roomId 命中(DM 无 thread 的场景) */
+  /*
+   * ② 房间号匹配(2026-11-12 重写,杜绝「一房多会话互相盖章」)。
+   *
+   * 🔴 原实现 `sessionKey.includes(roomId)` 是子串匹配,酿成实际事故:
+   *    老架构 51 条会话共享同一个 `!roomId`,一条在跑时
+   *    **同房间所有会话全部命中** → 「明明没回复也在转」(用户实测)。
+   *
+   * 现在的判据(**不放宽成任何子串**):
+   *   a) `sessionKey` **整体等于** roomId
+   *   b) `sessionKey` 以 `:` 分隔后 **最后一段等于** roomId,且该段以 `!` 开头
+   *      (兼容容器 `agent:main:matrix:dm:!roomA` 这类带前缀的写法)
+   *
+   * 反例(必须**不**命中):
+   *   `!roomA:thread_x`     ← 同一房间里的**另一个**会话(曾被误判,这是关键)
+   *   `!roomAxxx`           ← 不同房间
+   *
+   * 这样「只在房间号上报」时仍可用,同时不会把同房间的不同会话混为一谈。
+   */
   if (roomIdOf) {
-    return pool.find((s) => s.sessionKey.includes(roomIdOf));
+    const exact = pool.find((s) => {
+      const key = s.sessionKey;
+      if (key === roomIdOf) return true;
+      const last = key.split(':').pop() ?? '';
+      return last === roomIdOf && last.startsWith('!');
+    });
+    if (exact) return exact;
   }
   return undefined;
 }
@@ -436,10 +458,27 @@ export function sessionRunning(
   roomIdOf?: string | null,
   myUserId?: string | null,
   now = Date.now(),
+  /**
+   * 本地「用户刚在这条会话发出消息」的时刻(`sessionLocalStartAt`)。
+   *
+   * 🔴 用来判「容器这条 `idle` 是不是**旧结论**」(2026-11-12 用户实测)。
+   *
+   * 场景:用户刚发消息,容器 `currentSessions` 里还留着上一轮写下的
+   * `idle` 记录(实测 `turnStartAt`→`updatedAt` 只差 1 秒,说明是**收尾那一刻**
+   * 写的)。这条记录比本轮提问还早,却被当成「容器说本条空闲」的权威结论 →
+   * 左侧刚发出的会话**一下都不转**。
+   *
+   * 规则:`idle` 记录的 `updatedAt` **早于**本地发出时刻 → 不采信(返回 `undefined`
+   * 让调用方回落到本地信号);只有容器在该时刻**之后**又报过 `idle`,才算数。
+   */
+  localStartAt?: number,
 ): boolean | undefined {
   const s = sessionStateOf(sessions, appSessionId, roomIdOf, myUserId);
   if (!s) return undefined;
-  if (s.status !== 'running') return false;
+  if (s.status !== 'running') {
+    if (localStartAt !== undefined && s.updatedAt && s.updatedAt < localStartAt) return undefined;
+    return false;
+  }
   /*
    * `running` 也要有保质期:`updatedAt` 很久没变 → 不可信。
    * 没有 `updatedAt` 的(老容器)不做过期判定,保持原行为。
@@ -447,3 +486,32 @@ export function sessionRunning(
   if (s.updatedAt && now - s.updatedAt > SESSION_RUNNING_STALE_MS) return undefined;
   return true;
 }
+
+/**
+ * 该会话是否**明确空闲**(容器说 idle,且这条结论仍然新鲜)。
+ *
+ * 🔴 与 `sessionRunning()` 的区别(2026-11-12):
+ *    `sessionRunning() === false` 只表示「容器说不在跑」,
+ *    但容器会**长期保留历史记录**(实测最久 13.4 小时),
+ *    陈旧的 `idle` 与「这一轮刚结束」是两回事。
+ *
+ *    左侧转圈需要的是后者:只有**新鲜的 idle** 才能用来「结束转圈」。
+ *    拿 13 小时前的 idle 结束今天的转圈,等于让本地标记完全失效
+ *    (表现为「发送后一下都不转」)。
+ *
+ * @param idleMaxAgeMs 多久之内的 idle 才算数(默认 10 分钟,与 running 过期同口径)
+ */
+export function sessionIdleFresh(
+  sessions: AgentSessionState[] | undefined,
+  appSessionId: string | null | undefined,
+  roomIdOf?: string | null,
+  myUserId?: string | null,
+  now = Date.now(),
+  idleMaxAgeMs = SESSION_RUNNING_STALE_MS,
+): boolean {
+  const s = sessionStateOf(sessions, appSessionId, roomIdOf, myUserId);
+  if (!s || s.status === 'running') return false;
+  /* 没有 updatedAt 的老容器:无从判断新鲜度,按「不采信」处理(宁可多转一会) */
+  if (!s.updatedAt) return false;
+  return now - s.updatedAt <= idleMaxAgeMs;
+}

+ 19 - 0
src/app/core/api/fmode-api.ts

@@ -46,6 +46,25 @@ export abstract class FmodeApi {
   /** Token 直登(REST R1:`POST /parse/users/me`) */
   abstract loginWithToken(token: string): Promise<AuthSession>;
 
+  /**
+   * 账号密码登录(2026-11-12)。
+   *
+   * `POST {parseServer}/login` → `sessionToken`,**与 Token 直登拿到的是同一种凭据**
+   * (`r:` 开头 34 位),所以登录后的链路完全复用。
+   *
+   * 用于康师傅账套这类「用户名=姓名拼音、密码统一发放」的账号 ——
+   * 他们既没有手机号走验证码,也不方便手动粘贴 Token。
+   */
+  abstract loginWithPassword(username: string, password: string): Promise<AuthSession>;
+
+  /**
+   * 取该 session 的过期时间(ISO 字符串;拿不到返回 null)。
+   *
+   * 用于「按 session 过期时间保证登录状态」:登录后记下来,
+   * 下次启动时本地先判一次,过期就直接回登录页,不必等请求报 401。
+   */
+  abstract fetchSessionExpiry(token: string): Promise<string | null>;
+
   /** 用已有 token 取回身份 */
   abstract fetchMe(token: string): Promise<Me>;
 

+ 14 - 0
src/app/core/api/mock-api.ts

@@ -67,6 +67,20 @@ export class MockApi extends FmodeApi {
     return { token, me: DEMO_ME };
   }
 
+  /** 演示态:任意非空账号 + 密码 `123456` 视为成功(与真实密码规则区分开) */
+  async loginWithPassword(username: string, password: string): Promise<AuthSession> {
+    await this.delay();
+    if (!username.trim() || !password) throw new Error('请输入账号和密码');
+    if (password !== '123456') throw new Error('账号或密码不正确');
+    return { token: 'demo-fmode', me: DEMO_ME };
+  }
+
+  /** 演示态:固定一年后 */
+  async fetchSessionExpiry(_token: string): Promise<string | null> {
+    await this.delay();
+    return new Date(Date.now() + 365 * 24 * 3600 * 1000).toISOString();
+  }
+
   async fetchMe(token: string): Promise<Me> {
     await this.delay();
     if (!token) throw new Error('未登录');

+ 68 - 0
src/app/core/api/parse-api.ts

@@ -180,6 +180,59 @@ export class ParseApi extends FmodeApi {
     return { token, me };
   }
 
+  /**
+   * 账号密码登录(2026-11-12 实测定稿)。
+   *
+   * `POST {parseServer}/login` `{ username, password }` → `{ sessionToken, ..._User }`
+   *
+   * 实测要点:
+   *   · 平台**开放该接口**(错误密码返回 Parse 标准 `code:101`,不是 404);
+   *   · 返回的 `sessionToken` 形如 `r:xxx`(34 位),**与 Token 直登完全一致**;
+   *   · 不按 company 过滤,所以康师傅(`FKieRbIoGt`)账号可直接登录,
+   *     与手机验证码用的 `LOGIN_COMPANY`(写死飞码)互不影响。
+   */
+  async loginWithPassword(username: string, password: string): Promise<AuthSession> {
+    const name = username.trim();
+    if (!name || !password) throw new Error('请输入账号和密码');
+    const res = await this.rawFetch(`${PARSE_BASE}/login`, {
+      method: 'POST',
+      body: JSON.stringify({ username: name, password }),
+    });
+    const d = (await res.json()) as {
+      code?: number;
+      error?: string;
+      sessionToken?: string;
+    };
+    const token = d.sessionToken;
+    if (!res.ok || !token) throw new Error(friendlyLoginError(d.code, res.status));
+    this.setToken(token);
+    const me = await this.fetchMe(token);
+    return { token, me };
+  }
+
+  /**
+   * 取 session 过期时间。
+   *
+   * `GET /parse/sessions/me` → `{ expiresAt: { __type:'Date', iso:'…' } }`
+   * 实测(2026-11-12)密码登录的 session 有效期为 **1 年**。
+   * 该接口不可用(403/404)时返回 null —— 此时退化为「不过期」,不阻断登录。
+   */
+  async fetchSessionExpiry(token: string): Promise<string | null> {
+    try {
+      const res = await this.rawFetch(`${PARSE_BASE}/sessions/me`, {
+        method: 'GET',
+        headers: { 'X-Parse-Session-Token': token },
+      });
+      if (!res.ok) return null;
+      const d = (await res.json()) as { expiresAt?: { iso?: string } | string };
+      const e = d.expiresAt;
+      if (!e) return null;
+      return typeof e === 'string' ? e : (e.iso ?? null);
+    } catch {
+      return null;
+    }
+  }
+
   async fetchMe(token: string): Promise<Me> {
     // R1:**GET** /parse/users/me(头 X-Parse-Session-Token)
     // 🔴 实测教训:POST 会 404(返回 HTML 错误页);契约文档曾误写为 POST。
@@ -1001,6 +1054,21 @@ function pickMessage(d: unknown): string | undefined {
  * 故此处把「缺参数」在**限流上下文**(HTTP 500 而非 400)下转成更可能的真相提示,
  * 并在文案中保留原始信息以便排查(不吞掉事实)。
  */
+/**
+ * 登录失败文案(2026-11-12)。
+ *
+ * 🔴 **不区分「账号不存在」和「密码错误」** —— 两者都回同一句,
+ * 避免被用来枚举系统里有哪些账号。
+ *
+ * Parse 的错误码:101 = Invalid username/password。
+ */
+function friendlyLoginError(code: number | undefined, status: number): string {
+  if (code === 101) return '账号或密码不正确';
+  if (status === 429) return '尝试过于频繁,请稍后再试';
+  if (status >= 500) return '登录服务暂时不可用,请稍后重试';
+  return `登录失败(HTTP ${status})`;
+}
+
 function friendlySendCodeError(raw: string | undefined, status: number): string {
   const m = raw ?? '';
   if (/频繁|过于频繁|too many|rate/i.test(m)) {

+ 23 - 0
src/app/core/chat-session.service.ts

@@ -763,6 +763,29 @@ export class ChatSessionService {
     return this._all().filter((s) => !s.archived && (s.workspaceId ?? null) === workspaceId).length;
   }
 
+  /**
+   * 给会话换一个房间(一房间一会话,2026-11-12)。
+   *
+   * 场景:新建对话时先在绑定房间建出会话(保证即时可用),
+   * 后台再为它开专属房间;房间落地后把 `roomId` 改过去。
+   *
+   * 与 `moveTo()` 同一纪律:**未落库的会话只改本地**,等首次发消息
+   * 随 `commit()` 一起写云端,避免「点一次新建就在云端留一条空记录」。
+   */
+  setRoomId(id: string, roomId: string): void {
+    if (!roomId) return;
+    const cur = this.byId(id);
+    if (!cur || cur.roomId === roomId) return;
+    this.update(id, { roomId });
+    if (this._pendingPush.has(id)) return;
+    void this.trySync(() => this.api!.upsertChatThreads([toUpsert(this.byId(id)!)]));
+  }
+
+  /** 该房间下已有哪些会话(迁房后用于确认「一房间一会话」没被破坏) */
+  sessionsInRoom(roomId: string): readonly ChatSession[] {
+    return this._all().filter((s) => s.roomId === roomId);
+  }
+
   /**
    * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。
    *

+ 120 - 4
src/app/core/chat.service.spec.ts

@@ -85,6 +85,10 @@ describe('ChatService', () => {
       .and.returnValue(Promise.resolve({ event_id: '$f' })),
     setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
     paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)),
+    /** 初次 sync 是否完成(回溯历史前会等它,2026-11-12) */
+    synced: jasmine.createSpy('synced').and.returnValue(Promise.resolve(true)),
+    /** token 失效自愈时用它定位容器(2026-11-12) */
+    setRecoveryAgentId: jasmine.createSpy('setRecoveryAgentId'),
     lastMessageEventId: jasmine.createSpy('lastMessageEventId').and.returnValue(null),
   };
 
@@ -131,7 +135,7 @@ describe('ChatService', () => {
     // 每个用例都重置附件发送策略 —— 否则上一用例的 reject 会漏到下一个
     mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$f' }));
     mxStub.paginateBack.and.returnValue(Promise.resolve(true));
-    // 云端会话目录:默认「该容器已有会话」
+    mxStub.synced.and.returnValue(Promise.resolve(true));
     apiStub.fetchChatThreads.and.returnValue(Promise.resolve([CLOUD_THREAD]));
     apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
     apiStub.patchChatThread.and.returnValue(Promise.resolve());
@@ -703,6 +707,12 @@ describe('ChatService', () => {
     });
 
     it('🔴 防重入:并发调用只触发一次 SDK 分页', async () => {
+      /*
+       * 🔴 调用计数必须从这个用例的基线算起(2026-11-12)。
+       *    `open()` 现在也会按需回溯历史,上一句 `beforeEach` 里的 open
+       *    可能已经调用过若干次 `paginateBack`;绝对计数会被累加污染。
+       */
+      const base = mxStub.paginateBack.calls.count();
       let resolveIt: (v: boolean) => void = () => undefined;
       mxStub.paginateBack.and.callFake(() => new Promise<boolean>((res) => (resolveIt = res)));
 
@@ -711,7 +721,7 @@ describe('ChatService', () => {
       resolveIt(true);
       await Promise.all([a, b]);
 
-      expect(mxStub.paginateBack.calls.count()).toBe(1);
+      expect(mxStub.paginateBack.calls.count() - base).toBe(1);
     });
 
     it('paginating 在完成后复位', async () => {
@@ -720,8 +730,111 @@ describe('ChatService', () => {
     });
   });
 
-  describe('setDraft 触发 typing 通知', () => {
-    it('有内容 → typing=true,清空 → typing=false', async () => {
+  /*
+   * 🔴 历史会话打开为空(2026-11-12 用户实测)。
+   *
+   * 现象:「我的这些历史记录都无法查看了,大部分都没有正常显示。」
+   * 根因:sync 只保留房间最近 50 条,老会话的消息不在本地 timeline,
+   *       而打开会话时**从不主动向前翻**,于是渲染成「全新会话」欢迎页。
+   */
+  describe('打开历史会话时按需回溯 timeline', () => {
+    const SID = '$approot_test:fmode.cn';
+
+    /** 构造一条属于该会话的消息 */
+    const msgOf = (id: string): MxMessage =>
+      ({
+        roomId: '!room1:fmode.cn',
+        eventId: id,
+        sender: '@zgdcmgnqcd:fmode.cn',
+        threadId: SID,
+        body: '历史内容',
+        ts: 1,
+        kind: 'text' as const,
+        live: false,
+      }) as MxMessage;
+
+    afterEach(() => {
+      mxStub.timelineToMessages.and.returnValue([]);
+      mxStub.paginationToken.and.returnValue('tok-b');
+    });
+
+    it('本地没有该会话的消息 → 向前回溯并命中', async () => {
+      realMode();
+      // 第一轮:只有别的会话;回溯一次后:出现本会话
+      let calls = 0;
+      mxStub.timelineToMessages.and.callFake(() =>
+        calls === 0 ? [] : [msgOf('$h1'), msgOf('$h2')],
+      );
+      mxStub.paginateBack.and.callFake(() => {
+        calls++;
+        return Promise.resolve(true);
+      });
+
+      await svc.open('agent-node-lj-test', SID);
+
+      expect(mxStub.paginateBack).toHaveBeenCalled();
+      expect(svc.messages().length).toBeGreaterThan(0);
+      expect(svc.emptyReason()).toBeNull();
+    });
+
+    it('命中即停:不把整个房间翻完', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([msgOf('$h1')]);
+      await svc.open('agent-node-lj-test', SID);
+      expect(mxStub.paginateBack).not.toHaveBeenCalled();
+    });
+
+    it('页面已到房间最早(无 token)→ 不回溯', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      mxStub.paginationToken.and.returnValue(null);
+      await svc.open('agent-node-lj-test', SID);
+      expect(mxStub.paginateBack).not.toHaveBeenCalled();
+    });
+
+    it('🔥 回溯耗尽仍取不到 → emptyReason=unloaded,不得伪装成新会话', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]); // 永远取不到
+      mxStub.paginateBack.and.returnValue(Promise.resolve(true));
+
+      await svc.open('agent-node-lj-test', SID);
+
+      /*
+       * 断言「**有上限**」而不是写死具体轮数(2026-11-12):
+       * 上限会随房间规模调整(当前 50 轮,因 lj-test 房间已超 2100 个事件)。
+       * 这里守住真正的不变量:确实尝试过、且**一定会停**。
+       */
+      const n = mxStub.paginateBack.calls.count();
+      expect(n).toBeGreaterThan(0);
+      expect(n).toBeLessThanOrEqual(100);
+      /*
+       * 房间**没翻到最早**(`paginationToken` 仍返回 token)→ 不能断定「这条会话没有消息」,
+       * 只能诚实说「暂时取不到」。
+       */
+      expect(svc.emptyReason()).toBe('unloaded');
+    });
+
+    it('翻到房间最早 + 目录里有记录 → true empty(fresh),不吓唬用户', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      // 一轮就翻到顶:翻完 token 变 null
+      mxStub.paginateBack.and.callFake(() => {
+        mxStub.paginationToken.and.returnValue(null);
+        return Promise.resolve(true);
+      });
+
+      await svc.open('agent-node-lj-test', SID);
+      /*
+       * 🔴 已翻到房间最早 + 目录里有这条 + 0 条消息 = **空壳会话**(2026-11-12)。
+       *    不能报 `fresh`(会被渲染成「全新会话」欢迎语,诱导用户以为内容丢了),
+       *    也不能报 `unloaded`(会让用户一直等「暂时取不到」)。
+       *    实测样本:「测试9680」在 Matrix 里确实一条消息都没有。
+       */
+      expect(svc.emptyReason()).toBe('blank');
+    });
+  });
+
+  describe('setDraft 触发 typing 通知', () => {    it('有内容 → typing=true,清空 → typing=false', async () => {
       realMode();
       await svc.open('a');
       svc.setDraft('在输入');
@@ -1144,6 +1257,9 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
     ),
     storeVersion: () => 0,
     timelineIndex: () => new Map<string, number>(),
+    /* 2026-11-12:openReal 现在会等初次 sync、并登记自愈用的 agentId */
+    synced: jasmine.createSpy('synced').and.returnValue(Promise.resolve(true)),
+    setRecoveryAgentId: jasmine.createSpy('setRecoveryAgentId'),
   };
 
   beforeEach(() => {

+ 424 - 10
src/app/core/chat.service.ts

@@ -208,10 +208,24 @@ export class ChatService {
    *
    * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
    *    用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
+   *
+   * `'blank'`(2026-11-12):**已翻到房间最早**、目录里有这条、但 Matrix 里
+   * 一条消息都没有 —— 这是「空壳会话」,不是「暂时取不到」。
+   * 说成「暂时」会让用户一直等、以为数据丢了(实测「测试9680」)。
    */
-  private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'removed' | null>(null);
+  private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'blank' | 'removed' | null>(null);
   readonly emptyReason = this._emptyReason.asReadonly();
 
+  /**
+   * 本次打开会话时,是否已经把**整个房间**的历史翻到底(2026-11-12)。
+   *
+   * 🔴 不能直接看 `paginationToken(rid) == null` 来判断:
+   *    回溯有轮数上限(50 轮 × 100 条),大房间(lj-test 已超 5000 事件)
+   *    翻满上限时 token 仍然存在,但**我们并没有真的翻完**。
+   *    只有这个字段为 true,才能断定「房间翻遍了、确实没有这条会话的消息」。
+   */
+  private _reachedRoomStart = false;
+
   readonly agentId = this._agentId.asReadonly();
   /** 当前会话 id(线程根) */
   readonly sessionId = this._sessionId.asReadonly();
@@ -279,6 +293,9 @@ export class ChatService {
       this._sessionId(),
       this.roomId,
       this.myMxId(),
+      Date.now(),
+      /* 刚发出的会话,上一轮留下的 idle 不算数(2026-11-12,见 sessionRunning 注释) */
+      this._sessionId() ? this.pulse.sessionLocalStartAt(this._sessionId()!) : undefined,
     );
     return running === undefined ? undefined : !running;
   });
@@ -404,6 +421,8 @@ export class ChatService {
   private unbindTyping: (() => void) | null = null;
   /** 容器 ✅/❌ reaction 订阅解绑(2026-11-11,本轮结束信号) */
   private unbindTurnReaction: (() => void) | null = null;
+  /** 容器推送的回合状态事件(2026-11-12,方案 2) */
+  private unbindTurnStatus: (() => void) | null = null;
   /** 全局未读监听解绑(仅登出/销毁时调用) */
   private unbindUnread: (() => void) | null = null;
   /**
@@ -733,6 +752,75 @@ export class ChatService {
     }
   }
 
+  /**
+   * 新建会话并**等专属房间就绪**后再返回(2026-11-12 用户要求)。
+   *
+   * ## 为什么不复用 `startBlank`
+   *
+   * `startBlank` 里建房是 fire-and-forget:用户「点新建 → 立刻打字发送」时,
+   * 消息会在房间建好**之前**发出 → 落进共享房间 → 一房间一会话失效。
+   * 实测后果:5 条测试会话全部还在 `!miyB1Dx…` 共享房间里,
+   * 继而导致左侧状态互相干扰(转圈 120 秒不停)。
+   *
+   * ## 做法
+   *
+   * 先建会话(保证即时可用),再**同步等**建房 + 容器进房
+   * (实测 598~717ms),完成后才返回给调用方去跳转页面。
+   *
+   * 建房失败**不阻塞**:降级为共享房间(老行为),用户仍可正常对话。
+   *
+   * @returns 会话对象;容器未就绪时返回 null
+   */
+  async startBlankWithRoom(
+    agentId: string,
+    workspaceId: string | null = null,
+  ): Promise<ChatSession | null> {
+    const s = await this.startBlank(agentId, workspaceId);
+    if (!s) return null;
+    /* 同步等建房完成(内部已 try/catch,失败静默降级) */
+    await this.provisionSessionRoom(s.id, agentId);
+    return s;
+  }
+
+  /**
+   * 为新建的会话开一个**独立房间**(一房间一会话,2026-11-12)。
+   *
+   * 见 `doStartBlank` 中的调用处注释。这里只做「建房 + 等容器进房 + 回写 roomId」,
+   * **任何一步失败都静默降级**为绑定房间的老行为 —— 新建对话绝不能因此失败。
+   *
+   * @param sessionId 新建的会话 id(同时作为该会话的 thread 根)
+   * @param agentId   所属容器
+   */
+  private async provisionSessionRoom(sessionId: string, agentId: string): Promise<void> {
+    const agent = this.live.agentById(agentId);
+    const agentMxId = this.provision?.agentMxId ?? MatrixClientService.agentMxId(agent?.id ?? '');
+    if (!agentMxId) return;
+    try {
+      const roomId = await this.mx.createSessionRoom(agentMxId, agent?.name);
+      /* 等容器进房:不等就发消息,容器可能还没加入、收不到(实测进房约 3 秒) */
+      const joined = await this.mx.waitAgentJoined(roomId, agentMxId, 30_000);
+      if (!joined) {
+        /*
+         * 容器没进房 → **放弃这个房间**,退回绑定房间。
+         * 不删房间(Matrix 无删除语义),只是不用它;
+         * 它没有消息,也不会出现在会话目录里(目录由发消息触发落库)。
+         */
+        return;
+      }
+      /* 回写 roomId:之后该会话的消息、状态、回溯全部基于这个房间 */
+      this.sessions.setRoomId(sessionId, roomId);
+      /*
+       * 若用户此时正停在这条会话上,把当前房间指针也切过去,
+       * 让「发送」直接落到新房间(否则会发进老房间,状态又混在一起)。
+       */
+      if (this._sessionId() === sessionId) {
+        this.roomId = roomId;
+      }
+    } catch {
+      /* 离线 / 限流 / 服务端拒绝:保持绑定房间(老行为),不影响用户 */
+    }
+  }
+
   private async doStartBlank(
     agentId: string,
     workspaceId: string | null = null,
@@ -780,6 +868,36 @@ export class ChatService {
     // 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
     this.sessions.pruneEmpty(s.id);
 
+    /*
+     * 🔴 一房间一会话(2026-11-12 架构升级)。
+     *
+     * 老架构:所有会话共用一个 DM 房间,容器只能上报 `!roomId`,
+     * App 无法区分是哪条会话 → 「一条在跑,全房间都转」(用户实测)。
+     *
+     * 新架构:**每条新会话开一个独立房间**,房间号即会话标识,容器上报无歧义。
+     *
+     * ## 失败降级(重要)
+     *
+     * 建房是网络操作,可能失败(离线、homeserver 限流)。
+     * 失败时**退回绑定房间**(老行为),会话仍然可用,只是状态隔离降级 ——
+     * 绝不能因为建房失败就让用户「新建对话」失败。
+     *
+     * ## 不阻塞首屏
+     *
+     * 建房 + 等容器进房最多几秒。**不能 fire-and-forget**(2026-11-12 实测踩坑):
+     * 原先写 `void this.provisionSessionRoom(...)`,用户紧接着发消息时
+     * `this.roomId` 还是共享房间 → 首条消息进了老房间,一房间一会话彻底失效。
+     *
+     * 🔴 建房**不在这里做**(2026-11-12 修正)。
+     *
+     * 原先写 `void this.provisionSessionRoom(...)` 或挂 `_sessionRoomInFlight`,
+     * 本质都是「先返回、后台建房」—— 用户秒发消息时房间还没好,
+     * 消息落进共享房间(实测 5 条会话全在 `!miyB1Dx…`)。
+     *
+     * 现在由调用方 `startBlankWithRoom()` 在返回前**同步等建房完成**;
+     * `startBlank()` 仍保留「不建房」的轻量语义(给不需要房间的场景用)。
+     */
+
     // 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
     this._sessionId.set(s.id);
 
@@ -892,7 +1010,7 @@ export class ChatService {
      * 由 Hermes 内建行为发出,不依赖插件改造。
      */
     this.unbindTurnReaction =
-      this.mx.onTurnReaction?.(rid, (_ok, sender) => {
+      this.mx.onTurnReaction?.(rid, (_ok, sender, targetId) => {
         /* 同 `bindRoomSubscriptions` 的说明:这里不校验全局 `seq` */
         /*
          * 必须校验 sender:群聊里 @ 了多个容器时,每个容器各打一个 ✅、
@@ -901,6 +1019,19 @@ export class ChatService {
          */
         const agentMxId = this._agentMxId();
         if (agentMxId && sender && sender !== agentMxId) return;
+        /*
+         * 🔴 用 ✅ 标注的那条消息反推「哪条会话结束了」(2026-11-12,用户实测)。
+         *
+         * 现象:**没打开**的会话左侧一直转圈,点开才停。
+         * 根因:`SessionTree.sessionRunningOf()` 只在「当前打开的会话」上才用
+         * reaction 收尾,其余会话只能等容器 `currentSessions` 上报过期,
+         * 而本地「刚发出」标记的 TTL 更长。
+         *
+         * ✅ 的 `m.relates_to.event_id` 指向容器本轮的最后一条消息,
+         * 它的 `threadId`(或回落到 roomId)就是结束的那条会话。
+         * 这里不依赖「当前打开的是哪条」,所以后台会话同样能收尾。
+         */
+        this.pulse.clearSessionLocalStartFor(this._sessionsOfMessage(targetId, rid));
         /*
          * 稍等一拍再取「末条 agent 消息 id」:reaction 与它标注的消息是
          * 两个独立事件,到达顺序不保证。让出一拍能避免取到上一条。
@@ -912,6 +1043,59 @@ export class ChatService {
           void this.live.load(true);
         }, 300);
       }) ?? null;
+
+    /*
+     * 容器主动推送的「本轮结束」事件(2026-11-12,方案 2)。
+     *
+     * 容器 turn 结束时立即上报云函数(实测 1 秒内),但 App 拉云函数是 15 秒轮询
+     * → 左侧最多要转 30 秒。这个订阅让容器同时往房间推一个 Matrix 事件,
+     * App 收到即刷新,把「停止转圈」的延迟从 30 秒压到 1~2 秒。
+     *
+     * 🔴 与 ✅ reaction 无关:✅ = 单条消息发完;本事件 = 整轮结束。
+     *
+     * 🔴 **全局订阅**(2026-11-12 修正):一房间一会话后,左侧每条会话都在自己的
+     *    房间里。只监听当前房间会漏掉后台会话的结束事件(用户实测「回复完了还转很久」)。
+     */
+    this.unbindTurnStatus =
+      this.mx.onTurnStatus?.((sessionKey, status, sender, evRoomId) => {
+        /* 只认本容器发的(群聊里别的容器也会推) */
+        const agentMxId = this._agentMxId();
+        if (agentMxId && sender && sender !== agentMxId) return;
+        /*
+         * 用 sessionKey 或事件所在房间定位会话 —— 容器两种形态都可能上报。
+         */
+        const sid = this._sessionIdOfKey(sessionKey) || this._sessionIdOfKey(evRoomId);
+        if (status !== 'idle') {
+          /* running:立刻刷新,让转圈尽快出现 */
+          void this.live.load(true);
+          return;
+        }
+        /*
+         * 🔴 idle:三重保险立刻停止左侧转圈(不等 15 秒轮询)
+         *   ① 清掉该会话的本地「刚发出」标记
+         *   ② 在本地打一个「已结束」时间戳,`sessionRunningOf()` 据此直接判不转
+         *   ③ 刷新容器状态(拉一次云函数,让其它界面也同步)
+         */
+        if (sid) {
+          this.pulse.clearSessionLocalStartFor([sid]);
+          this.pulse.markSessionEnded(sid);
+        }
+        void this.live.load(true);
+      }) ?? null;
+  }
+
+  /**
+   * 把容器上报的 sessionKey(`$approot_xxx` 或 `!roomId`)换算成本地会话 id。
+   *
+   * 一房间一会话下,房间号即会话 id,两种形态都能定位到会话。
+   */
+  private _sessionIdOfKey(sessionKey: string): string {
+    if (!sessionKey) return '';
+    const direct = this.sessions.byId(sessionKey);
+    if (direct) return direct.id;
+    /* roomId 形态:找 roomId 等于它的那条会话 */
+    const byRoom = this.sessions.sessionsInRoom(sessionKey)[0];
+    return byRoom?.id ?? '';
   }
 
   /**
@@ -942,6 +1126,11 @@ export class ChatService {
     this.provision = prov;
 
     // ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
+    /*
+     * 告诉 MatrixClientService「当前连的是哪个容器」——
+     * token 失效时它要用这个 agentId 调 `validateMatrix()` 重签凭据(2026-11-12)。
+     */
+    this.mx.setRecoveryAgentId(agentId);
     try {
       await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
     } catch (e) {
@@ -955,9 +1144,20 @@ export class ChatService {
     if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
 
     let rid: string;
+    /*
+     * 🔴 会话已有**专属房间**时直接用它(一房间一会话,2026-11-12)。
+     *
+     * 新建对话会为它开独立房间并回写到 `s.roomId`;再打开这条会话时必须进入
+     * **那个**房间,否则消息会发进绑定房间、状态又混到一起。
+     *
+     * 判据:显式指定了 sessionId,且本地目录里这条会话的 roomId 与绑定房间不同。
+     * 老会话(仍在绑定房间)不受影响,自动走原路径。
+     */
+    const explicitSession = sessionId ? this.sessions.byId(sessionId) : undefined;
+    const ownRoom = explicitSession?.roomId;
     try {
       rid = await this.mx.ensureRoom(agentMxId, {
-        roomId: prov.roomId,
+        roomId: ownRoom && ownRoom !== prov.roomId ? ownRoom : prov.roomId,
         agentName: this.live.agentById(agentId)?.name,
       });
     } catch (e) {
@@ -996,7 +1196,12 @@ export class ChatService {
      *    同一个房间里会有多条会话的消息混在 timeline 里,
      *    不按 `threadId` 过滤就会把别的会话的内容显示在当前会话里。
      */
-    const rawTimeline = this.mx.timelineToMessages(rid);
+    /*
+     * 🔴 首次读取放在这里只是为了让 `lastMessageAtOf()` 能用;
+     *    回溯之后**必须重新读一次**(见下方 ⑤-0 之后的 `rawTimeline` 赋值)——
+     *    否则补回来的历史不被采用,用户依然看到空白。
+     */
+    let rawTimeline = this.mx.timelineToMessages(rid);
 
     /*
      * 某会话在 timeline 里的**真实最后消息时间**(2026-11-07)。
@@ -1144,6 +1349,23 @@ export class ChatService {
     }
     if (my !== this.seq) return;
     this._sessionId.set(sid);
+
+    /*
+     * 🔴 ⑤-0 按会话回溯本地 timeline(2026-11-12 用户实测:「历史记录都无法查看」)。
+     *
+     * 问题:`startClient({ initialSyncLimit: 50 })` 只把房间**最近 50 条**
+     * 放进本地 timeline,而 `timelineToMessages()` 只读本地、**从不主动向前翻**。
+     * 实测房间有 1252 条消息,老会话的最新一条排在第 52 / 68 位 —— 压根不在本地,
+     * 打开就是空白,还被渲染成「全新会话」欢迎页(`emptyReason` 判成了 `fresh`)。
+     *
+     * 必须在 `sid` 确定之后做(要按会话过滤判断「命中没有」),
+     * 且必须在下面读 `rawTimeline` **之前**做。
+     */
+    await this.backfillSessionTimeline(rid, sid, my);
+    /* 回溯期间用户可能已切走 —— 过期就不继续(沿用全局 `seq` 版本号约定) */
+    if (my !== this.seq) return;
+    /* 🔴 回溯后**重新读取**:不重读就等于没回溯(历史仍取不到) */
+    rawTimeline = this.mx.timelineToMessages(rid);
     /*
      * 🔴 会话目录被整表替换后,会话可能连着被删两次(2026-11-06)。
      *    循环里 `byId` 只在那一刻成立:清理在别处跑过一轮,它就不在了。
@@ -1233,12 +1455,51 @@ export class ChatService {
      *   ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
      *   ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
      *
-     * 判据:rawTimeline 里有没有**任何**属于这个房间的消息。
-     * 有消息但过滤后为空 = 我们的归属规则把它们挡掉了,属于 ②。
-     * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条)。
+     * 判据(2026-11-12 修正):**这条会话在本地 timeline 里到底有没有消息**。
+     *
+     * 🔴 原判据是 `rawTimeline.length > 0`(房间里有没有任何事件)——
+     *    它恒为真(最近 50 条任何会话的都算),等于没有判断,
+     *    结果「有历史但取不到」也被渲染成「全新会话欢迎页」,
+     *    用户以为记录丢了(实测:51 条会话里大部分都这样)。
+     *
+     * 现在:
+     *   · 有消息                                  → 正常渲染
+     *   · 无消息,但房间里**确实有本会话的事件**  → `unloaded`(诚实说「取不到」)
+     *   · 无消息,但**会话目录说它该有内容**        → `unloaded`(诚实说「取不到」)
+     *   · 无消息,且目录里也没有它的记录            → `fresh`(真的是新会话)
+     *
+     * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条),
+     * 所以只看「目录里有没有这条记录」这个二值事实,不比对条数。
      */
-    const hadAnyEvent = rawTimeline.length > 0;
-    this._emptyReason.set(this._messages().length > 0 ? null : hadAnyEvent ? 'unloaded' : 'fresh');
+    const hasSessionEvent = rawTimeline.some((m) => this.belongsToSession(m, sid, true));
+    /*
+     * 「是不是真新会话」看**会话目录**:本地目录里有这条记录 = 它出现过。
+     * (新会话在落库前只有 `_blankSessionId` 标记,不会出现在目录里。)
+     *
+     * 🔴 三种空态要分清(2026-11-12 实测,用户会直接看这句话判断记录丢没丢):
+     *   · 回溯后**找到了**本会话事件但被归属规则挡掉      → `unloaded`(取不到)
+     *   · 已翻到房间最早、目录里有这条、仍无任何它的消息  → `fresh`(它本来就没有消息)
+     *   · 没翻到最早(可能还有更早的历史没找)            → `unloaded`(诚实说还没找到)
+     *
+     * 「已翻到房间最早」用 `paginationToken == null` 判定 —— 这时
+     * **整个房间都找过了**,没有就是真的没有,不该再吓唬用户说「取不到」。
+     */
+    const reachedRoomStart = this._reachedRoomStart;
+    const knownInCatalog = !!this.sessions.byId(sid);
+    this._emptyReason.set(
+      this._messages().length > 0
+        ? null
+        : hasSessionEvent
+          ? 'unloaded'
+          : reachedRoomStart && knownInCatalog
+            ? /*
+               * 已翻到房间最早 + 目录里有这条 + 一条消息都没有 = **空壳会话**。
+               * 不能说「暂时取不到」(用户会一直等,以为数据丢了),
+               * 也不能说成「新会话」(会诱导用户在这里重新开始)。
+               */
+              'blank'
+            : 'unloaded',
+    );
     this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
     // timeline pagination token 是否存在 = 更早可能有历史
     this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
@@ -1459,6 +1720,7 @@ export class ChatService {
      *    重建拿到一堆空房间,把群历史覆盖成空(评审 2026-11-07 发现)。
      */
     if (!opts.skipConnect || !this.mx.connected) {
+      this.mx.setRecoveryAgentId(agentId);
       await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
     }
     this.roomAgentMap.set(agentId, prov.roomId ?? '');
@@ -2127,6 +2389,110 @@ export class ChatService {
     return { ok, fail };
   }
 
+  /**
+   * 打开会话时,若本地 timeline 里**找不到这条会话的消息**,沿房间历史向前回溯。
+   *
+   * ## 为什么需要(2026-11-12 用户实测)
+   *
+   * 「我的这些历史记录都无法查看了,大部分都没有正常显示。」
+   *
+   * - `startClient({ initialSyncLimit: 50 })` 只把房间**最近 50 条**事件放进本地;
+   * - `timelineToMessages()` 只读本地 timeline,**从不主动向前翻**;
+   * - 实测 lj-test 房间有 1252 条消息、51 条会话,老会话的最新一条排在第 52 / 68 位
+   *   → 本地压根没有 → 打开是空白,且被渲染成「全新会话」欢迎页。
+   *
+   * ## 策略
+   *
+   * · **命中即停**:多数情况 1~2 轮就够,不把整个房间翻完(那是几十次往返);
+   * · 有上限(`MAX_ROUNDS` × `PAGE`),防止对超大房间无限回溯;
+   * · 翻到 token 耗尽(房间到顶)也停;
+   * · 复用 `belongsToSession()` 作为归属唯一真源,**不另写一套匹配规则**。
+   *
+   * 回溯失败(网络错误等)只记日志不抛错 —— 打开会话不该因为翻历史失败而整个失败。
+   *
+   * @param rid  房间 id
+   * @param sid  会话 id(线程根)
+   * @param my   `open()` 的版本号,切走即中止
+   * @returns    是否在本地找到了这条会话的消息
+   */
+  private async backfillSessionTimeline(rid: string, sid: string, my: number): Promise<boolean> {
+    /* 每次打开会话都要重新判定(沿用上一条会话的结论会误判成「翻到底了」) */
+    this._reachedRoomStart = false;
+    if (!sid || environment.useMockData) return true;
+    const hit = (): boolean =>
+      this.mx.timelineToMessages(rid).some((m) => this.belongsToSession(m, sid));
+    if (hit()) return true;
+    /*
+     * 🔴 新建会话本来就没有历史 —— 不能回溯,否则会把旧内容认领进来
+     * (这正是 2026-11-08「新建会话带出历史」的入口之一)。
+     */
+    if (this._blankSessionId === sid) return false;
+
+    /*
+     * 🔴 **必须等初次 sync 落地再回溯**(2026-11-12 实测)。
+     *
+     * `paginationToken()` 在房间还没进本地 store(`client.getRoom()` 为 null)
+     * 时返回 `null`,与「真的翻到顶了」无法区分。原写法把它当成「已到最早」
+     * 直接 break → 一次请求都不发 → 历史照样看不到(实测 requests=0)。
+     *
+     * `synced()` 会等首个 SYNCING(超时即返回 false,不无限等待)。
+     */
+    await this.mx.synced(15_000);
+    if (my !== this.seq) return false;
+
+    const PAGE = 100;
+    /*
+     * 🔴 上限要够大(2026-11-12 实测)。
+     *
+     * lj-test 的房间已超过 **2100 个事件**(还在增长),老的 10 轮 × 100 = 1000 条
+     * 根本翻不到底,「测试9680」这类会话的最新一条在第 207 位之外仍取不到。
+     * 这里抬到 50 轮(约 5000 条),同时靠「命中即停」保证大多数情况只翻 1~2 轮,
+     * 以及「到顶即停」保证不会白跑。
+     */
+    const MAX_ROUNDS = 50;
+    /*
+     * 🔴 与 `paginate()` 共用同一把锁(`_paginating`)。
+     *
+     * 两者翻的是**同一条房间 timeline**:并发拉取会让 SDK 的 `end` token
+     * 互相覆盖,出现「翻了一轮等于没翻」甚至重复事件。
+     * 已有分页在跑时让路 —— 它翻完同样会让 `hit()` 成立。
+     */
+    if (this._paginating()) return hit();
+    this._paginating.set(true);
+    try {
+      for (let i = 0; i < MAX_ROUNDS; i++) {
+        if (my !== this.seq) return false;
+        if (this.mx.paginationToken(rid) == null) {
+          /* 已经翻到房间最早:本次判断「有没有这条会话的消息」是**定论** */
+          this._reachedRoomStart = true;
+          break;
+        }
+        let ok = false;
+        try {
+          ok = await this.mx.paginateBack(rid, PAGE);
+        } catch {
+          /* 网络抖动:不阻断打开会话(**不能**据此认为翻到底了) */
+          break;
+        }
+        /*
+         * 🔴 SDK 语义:`paginateEventTimeline` 返回 **false = 已到 timeline 尽头**
+         * (`client.ts:5054`「false if there are no events and we reached either end」),
+         * 不是出错。早先把它当错误处理,导致提前 break 且误判「翻到底」。
+         */
+        if (!ok) {
+          this._reachedRoomStart = true;
+          break;
+        }
+        if (hit()) return true;
+      }
+      /* 翻满上限还没到顶 —— 后面判定要当「可能还有没找到的历史」处理 */
+      if (this.mx.paginationToken(rid) == null) this._reachedRoomStart = true;
+      return hit();
+    } finally {
+      if (my === this.seq) this._paginating.set(false);
+    }
+  }
+
   /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
   async paginate(): Promise<boolean> {
     if (environment.useMockData || !this.roomId) return false;
@@ -2231,12 +2597,24 @@ export class ChatService {
     const lastId = this._messages().at(-1)?.id;
     // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
     const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
+    /*
+     * 🔴 等该会话的专属房间建好再发(2026-11-12)。
+     *    否则消息会落进共享房间,导致「一房间一会话」失效。
+     *    等不到(建房失败)就发进当前房间 —— 降级为老行为,不影响用户。
+     */
+    /*
+     * 🔴 发到**该会话自己的房间**(一房间一会话,2026-11-12)。
+     *
+     * 建房已在 `startBlankWithRoom()` 里同步完成(用户到达页面时房间已就绪),
+     * 所以这里只需按会话取 roomId;取不到才回落到当前房间(老会话 / 建房失败降级)。
+     */
+    const targetRoom = this.sessions.byId(sid)?.roomId ?? this.roomId;
     /*
      * `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
      * 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
      */
     await this.mx.sendText(
-      this.roomId,
+      targetRoom,
       text,
       sid,
       last,
@@ -2364,6 +2742,27 @@ export class ChatService {
   /** 当前派活锚点(供群聊页在冷启动时补种) */
   readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
 
+  /**
+   * 用 ✅ 标注的消息 eventId,反推「哪条会话刚结束」(2026-11-12)。
+   *
+   * ✅ 的 `m.relates_to.event_id` 指向容器本轮的最后一条消息。从房间 timeline
+   * 里找出这条消息,它的归属规则与 `belongsToSession()` 完全一致:
+   *   · 有 `m.thread` → 线程根就是会话 id(容器自开的子线程按规则归当前会话)
+   *   · 无 thread ──── DM 里容器的正常回复**本来就不挂 thread**,
+   *                     此时拿不到精确会话,返回 `null` 让调用方清全部本地标记
+   *                     (容器已经收工,继续转圈比少转一会儿更糟)
+   *
+   * @returns 命中的会话 id 列表;`null` = 无法精确定位
+   */
+  private _sessionsOfMessage(eventId: string, roomId: string): string[] | null {
+    if (!eventId) return null;
+    const hit = this.mx.timelineToMessages(roomId).find((m) => m.eventId === eventId);
+    if (!hit) return null;
+    if (hit.threadId) return [hit.threadId];
+    if (hit.sessionId) return [hit.sessionId];
+    return null;
+  }
+
   private belongsToSession(
     m: MxMessage,
     sessionId: string,
@@ -2913,6 +3312,19 @@ export class ChatService {
         fromAgent: mapped.sender === 'agent',
         ts: Date.parse(mapped.ts) || Date.now(),
       });
+      /*
+       * 会话级「最后一条 agent 消息」:左侧列表据此判断「在输出就转,没回复就停」。
+       *
+       * 🔴 必须过滤**历史回放**(2026-11-12 用户实测:点开历史会话就开始转)。
+       *    `appendIncoming` 会被 `/sync` 的历史事件触发,此时 `m.live` 为 false、
+       *    或消息时间戳很旧。不滤的话,打开任何老会话都会被判成「正在输出」→ 转圈。
+       *    判据与 `noteMessage` 同口径:实时到达 + 时间戳新鲜。
+       */
+      const ts = Date.parse(mapped.ts) || 0;
+      const fresh = ts > 0 && Date.now() - ts < 60_000;
+      if (mapped.sender === 'agent' && sid && m.live === true && fresh) {
+        this.pulse.noteSessionAgentMessage(sid);
+      }
     }
   }
 
@@ -2920,9 +3332,11 @@ export class ChatService {
     this.unbindMessage?.();
     this.unbindTyping?.();
     this.unbindTurnReaction?.();
+    this.unbindTurnStatus?.();
     this.unbindMessage = null;
     this.unbindTyping = null;
     this.unbindTurnReaction = null;
+    this.unbindTurnStatus = null;
   }
 
   private markSent(id: string): void {

+ 32 - 0
src/app/core/matrix-client.service.spec.ts

@@ -0,0 +1,32 @@
+import { isUnknownToken } from './matrix-client.service';
+
+/**
+ * `isUnknownToken()` 单测(2026-11-12)。
+ *
+ * 意义:SDK 对 `M_UNKNOWN_TOKEN` 会**永久停止** sync(`sync.ts:586`),
+ * 只有这一类错误需要自动重签凭据;判错了要么漏自愈(一直「连接已断开」),
+ * 要么对网络抖动反复重签(打爆云函数)。
+ *
+ * 🔴 只测这个纯函数 —— 不加载 matrix-js-sdk(与 chat.service.spec 同一约束)。
+ */
+describe('isUnknownToken(token 失效判定)', () => {
+  it('顶层 errcode 为 M_UNKNOWN_TOKEN → true', () => {
+    expect(isUnknownToken({ errcode: 'M_UNKNOWN_TOKEN' })).toBeTrue();
+  });
+
+  it('errcode 嵌在 data 里 → true(SDK 两种形状都出现过)', () => {
+    expect(isUnknownToken({ data: { errcode: 'M_UNKNOWN_TOKEN' } })).toBeTrue();
+  });
+
+  it('网络类错误 → false(交给 SDK 自己退避重连,不该重签凭据)', () => {
+    expect(isUnknownToken({ errcode: 'M_UNKNOWN' })).toBeFalse();
+    expect(isUnknownToken({ errcode: 'M_FORBIDDEN' })).toBeFalse();
+    expect(isUnknownToken(new Error('network'))).toBeFalse();
+  });
+
+  it('空值 / 非对象 → false(不抛错)', () => {
+    expect(isUnknownToken(undefined)).toBeFalse();
+    expect(isUnknownToken(null)).toBeFalse();
+    expect(isUnknownToken('M_UNKNOWN_TOKEN')).toBeFalse();
+  });
+});

+ 241 - 10
src/app/core/matrix-client.service.ts

@@ -1,6 +1,7 @@
 import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
 import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
 import { environment } from '../../environments/environment';
+import { FmodeApi } from './api/fmode-api';
 import { SessionService } from './session.service';
 
 /**
@@ -229,6 +230,19 @@ function escapeHtml(s: string): string {
  * 注意 `rel_type` 必须是 `m.thread` —— 其它关系类型(`m.replace` 编辑、
  * `m.reference` 引用)都不是线程,不能混为一谈。
  */
+/**
+ * 判断 sync 的错误是不是「token 失效」(2026-11-12)。
+ *
+ * SDK 对 `M_UNKNOWN_TOKEN` 会**永久停止** sync(`sync.ts:586`),
+ * 只有这一类错误值得自动重签凭据;网络类错误 SDK 自己会退避重连。
+ */
+export function isUnknownToken(err: unknown): boolean {
+  if (!err || typeof err !== 'object') return false;
+  const e = err as { errcode?: unknown; data?: { errcode?: unknown } };
+  const code = e.errcode ?? e.data?.errcode;
+  return code === 'M_UNKNOWN_TOKEN';
+}
+
 export function extractThreadId(content: Record<string, unknown> | undefined): string | undefined {
   const rel = content?.['m.relates_to'] as
     | { rel_type?: unknown; event_id?: unknown }
@@ -379,6 +393,9 @@ export class MatrixClientService implements OnDestroy {
   private globalBound = false;
 
   private readonly session = inject(SessionService);
+  private readonly api = inject(FmodeApi);
+  /** token 失效自愈的尝试次数(成功后归零,见 Sync 事件处理) */
+  private recoveryAttempts = 0;
 
   constructor() {
     // 🔴 登出即断连:不依赖页面记得调用 disconnect()。
@@ -464,11 +481,25 @@ export class MatrixClientService implements OnDestroy {
     }
 
     // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
-    this.client.on(sdk.ClientEvent.Sync, (state: string) => {
+    this.client.on(sdk.ClientEvent.Sync, (state: string, _prev: string | null, data?: { error?: unknown }) => {
       if (this.stopped) return;
-      if (state === 'SYNCING' || state === 'PREPARED') this._syncState.set('connected');
-      else if (state === 'ERROR') this._syncState.set('disconnected');
-      else if (state === 'RECONNECTING') this._syncState.set('connecting');
+      if (state === 'SYNCING' || state === 'PREPARED') {
+        this._syncState.set('connected');
+        this.recoveryAttempts = 0; // 恢复正常 → 重置退避
+      } else if (state === 'ERROR') {
+        this._syncState.set('disconnected');
+        /*
+         * 🔴 token 失效会让 SDK **永久**停掉 sync(2026-11-12 实测)。
+         *
+         * `matrix-js-sdk` 的 `SyncApi.shouldAbortSync()` 遇到 `M_UNKNOWN_TOKEN`
+         * 会直接 `this.stop()`(`sync.ts:586-595`)—— 之后网络恢复、页面一直在,
+         * sync 也都不会再起来,界面永远停在「连接已断开」,
+         * 只能靠用户手点「重新连接」(用户截图实证)。
+         *
+         * 这里做自愈:用 provision 的 `validate:1` 探活并重新签发凭据。
+         */
+        if (isUnknownToken(data?.error)) void this.recoverFromExpiredToken();
+      } else if (state === 'RECONNECTING') this._syncState.set('connecting');
       // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
       if (state === 'SYNCING') {
         this.initialSyncDone = true;
@@ -500,7 +531,7 @@ export class MatrixClientService implements OnDestroy {
     this.bindGlobalMessageStream();
 
     this.started = true;
-    this.client.startClient({ initialSyncLimit: 50 });
+    this.client.startClient({ initialSyncLimit: INITIAL_SYNC_LIMIT });
   }
 
   /** 断开并清理(**仅退出登录时**调用) */
@@ -534,6 +565,52 @@ export class MatrixClientService implements OnDestroy {
     return !!this.client;
   }
 
+  /**
+   * token 失效后自愈:重新签发凭据并重建连接(2026-11-12)。
+   *
+   * ## 为什么需要
+   *
+   * SDK 在 `M_UNKNOWN_TOKEN` 时会 `SyncApi.stop()` 永久停掉 sync,
+   * 网络恢复也不会自己回来 —— 用户看到的就是一直挂着的「连接已断开」。
+   *
+   * ## 做法
+   *
+   * 用 `validateMatrix()`(云函数侧 `validate:1`)探活并在失效时重签凭据,
+   * 拿到新 token 后 `connectSession()` 重建客户端。
+   *
+   * 🔴 必须**用现有 userId 重连**,而不是直接重连旧 token;
+   *    同时保留指数退避与次数上限,避免云函数故障时打爆服务端。
+   */
+  private async recoverFromExpiredToken(): Promise<void> {
+    if (this.stopped) return;
+    const userId = this._userId();
+    if (!userId) return;
+    const attempt = this.recoveryAttempts++;
+    if (attempt >= 3) return; // 上限:连续 3 次失败就交给用户手动「重新连接」
+    /* 指数退避:0s / 3s / 9s */
+    if (attempt > 0) await new Promise((r) => setTimeout(r, 3000 * attempt));
+    if (this.stopped) return;
+    this._syncState.set('connecting');
+    try {
+      const agentId = this.recoveryAgentId ?? '';
+      const prov = await this.api.validateMatrix(agentId);
+      if (!prov?.accessToken || prov.accessToken === this.token) return; // 没换到新凭据,别再重试
+      this.connectSession(prov.accessToken, prov.userMxId || userId, prov.deviceId);
+    } catch {
+      /* 网络/云函数故障:保持 disconnected,UI 仍有「重新连接」兜底 */
+    }
+  }
+
+  /**
+   * 记录「当前连的是哪个容器的房间」,供 token 失效自愈时调 `validateMatrix`。
+   *
+   * 取不到时传空串 —— `validateMatrix('')` 只校验用户账号级凭据,够用。
+   */
+  setRecoveryAgentId(agentId: string): void {
+    this.recoveryAgentId = agentId;
+  }
+  private recoveryAgentId = '';
+
   // ---------- 房间 ----------
 
   /**
@@ -587,6 +664,61 @@ export class MatrixClientService implements OnDestroy {
     return !!this.client?.getRoom(roomId)?.getMyMembership?.();
   }
 
+  /**
+   * 为**一条新会话**创建独立房间(一房间一会话,2026-11-12)。
+   *
+   * ## 为什么要这样
+   *
+   * 老架构里 51 条会话共用一个 DM 房间,容器只能上报房间号 `!roomId`,
+   * 而 App 无法据此区分「是 51 条里的哪一条」——
+   * 于是「一条会话在跑」会被误判成「同房间所有会话都在跑」(用户实测的乱转)。
+   *
+   * 一房间一会话后,**房间号本身就是会话的唯一标识**,容器上报无歧义。
+   *
+   * ## 实测依据(2026-11-12)
+   *
+   * · 客户端 `createRoom({invite:[agentMxId]})` → 容器 **3 秒内自动进房**;
+   * · 同时往两个房间发消息 → 容器 `currentSessions` 出现 **2 条独立的 running**,
+   *   各自回复正确内容,互不串台。
+   *
+   * 也就是说:**容器侧零改造**,这是它原生支持的模式。
+   *
+   * ⚠️ 代价:Matrix 房间数会增加(每个会话一个)。需要配套清理长期不活跃的房间,
+   *    否则 `/sync` 首包会越来越慢(实测 173 房间 / 5013 事件已约 1 秒)。
+   */
+  async createSessionRoom(agentMxId: string, agentName?: string): Promise<string> {
+    const c = this.requireClient();
+    const sdk = await this.load();
+    const created = await c.createRoom({
+      invite: [agentMxId],
+      is_direct: true,
+      preset: sdk.Preset.TrustedPrivateChat,
+      name: agentName,
+      /*
+       * 标记「这是一条会话房间」:便于日后按来源批量清理,
+       * 不会与新老房间混淆(老房间没有这个标记)。
+       */
+      creation_content: { 'fmode.session_room': true },
+    });
+    return created.room_id;
+  }
+
+  /**
+   * 等容器进入某个房间(最多 `timeoutMs`)。
+   *
+   * 新建房间后必须等容器进房才能发消息,否则容器收不到
+   * (它只监听自己已加入的房间)。实测进房约 3 秒。
+   */
+  async waitAgentJoined(roomId: string, agentMxId: string, timeoutMs = 30_000): Promise<boolean> {
+    const deadline = Date.now() + timeoutMs;
+    while (Date.now() < deadline) {
+      const room = this.client?.getRoom(roomId);
+      if (room?.getMember(agentMxId)?.membership === 'join') return true;
+      await new Promise((r) => setTimeout(r, 1000));
+    }
+    return false;
+  }
+
   // ---------- 消息收发 ----------
 
   /**
@@ -908,7 +1040,7 @@ export class MatrixClientService implements OnDestroy {
     if (this.started) return;
     this.started = true;
     try {
-      this.client.startClient({ initialSyncLimit: 50 });
+      this.client.startClient({ initialSyncLimit: INITIAL_SYNC_LIMIT });
     } catch {
       /* 已在运行等情况:忽略 */
     }
@@ -1126,11 +1258,13 @@ export class MatrixClientService implements OnDestroy {
    * @param cb 收到 ✅/❌ 时回调,参数为 true 表示成功、false 表示失败;
    *           第二个参数是发 reaction 的 mxid(群聊用来区分是哪个容器收工的)
    */
-  onTurnReaction(roomId: string, cb: (ok: boolean, sender: string) => void): () => void {
+  onTurnReaction(roomId: string, cb: (ok: boolean, sender: string, targetId: string) => void): () => void {
     const c = this.requireClient();
     const handler = (ev: MatrixEvent) => {
       if (ev.getRoomId() !== roomId || ev.getType() !== 'm.reaction') return;
-      const rel = (ev.getContent() as { 'm.relates_to'?: { key?: string } })?.['m.relates_to'];
+      const rel = (ev.getContent() as { 'm.relates_to'?: { key?: string; event_id?: string } })?.[
+        'm.relates_to'
+      ];
       const key = rel?.key ?? '';
       /*
        * 🔴 必须把 sender 传出去(2026-11-11 容器侧代码取证指出)。
@@ -1141,8 +1275,79 @@ export class MatrixClientService implements OnDestroy {
        *
        * 调用方要用 sender 和「本块对应的 agent」比对,见 `chat.service`。
        */
-      if (key === '✅') cb(true, ev.getSender() ?? '');
-      else if (key === '❌') cb(false, ev.getSender() ?? '');
+      if (key === '✅') cb(true, ev.getSender() ?? '', rel?.event_id ?? '');
+      else if (key === '❌') cb(false, ev.getSender() ?? '', rel?.event_id ?? '');
+    };
+    c.on('Room.timeline' as never, handler as never);
+    return () => {
+      try {
+        c.off('Room.timeline' as never, handler as never);
+      } catch {
+        /* 忽略 */
+      }
+    };
+  }
+
+  /**
+   * 订阅**容器主动推送的回合状态事件**(2026-11-12,方案 2)。
+   *
+   * ## 为什么需要
+   *
+   * 容器 `post_llm_call` 时会把 `extra.currentSessions` 的 status 改成 `idle` 并
+   * **立即**走云函数上报(实测 turn 结束 1 秒内完成)。但 App 拉云函数是
+   * **15 秒轮询**,所以「容器 17 秒报 idle → App 最迟 32 秒才知道」——
+   * 用户看到左侧要转半分钟才停。
+   *
+   * 这个订阅让容器在 turn 结束时**直接往房间推一个 Matrix 事件**,
+   * App 收到即刷新,无需等轮询。
+   *
+   * ## 协议(与容器约定,二者任选其一即可)
+   *
+   * **A(首选)自定义事件类型** `com.fmode.turn_status`:
+   * ```json
+   * { "sessionKey": "$approot_xxx | !roomId", "status": "idle|running" }
+   * ```
+   *
+   * **B(兜底)** `m.room.message` 带 `fmode.turn_status` 字段(同结构)。
+   * 之所以保留 B:容器若无法发送非 `m.room.message` 事件,可用消息体携带,
+   * 但 App 侧**不得把它当普通消息渲染**(本方法只解析、不入会话列表)。
+   *
+   * 🔴 **与 ✅ reaction 完全无关**:✅ 只代表「这条消息发完了」,
+   * 本事件才代表「这一轮结束了」。两者并存,互不替代。
+   *
+   * @param roomId 房间
+   * @param cb     收到状态事件时回调:会话 key + 新状态 + 发送者 mxid
+   */
+  onTurnStatus(
+    cb: (sessionKey: string, status: string, sender: string, roomId: string) => void,
+  ): () => void {
+    const c = this.requireClient();
+    const handler = (ev: MatrixEvent) => {
+      /*
+       * 🔴 **监听所有房间**,不限定 roomId(2026-11-12 修正)。
+       *
+       * 一房间一会话之后,左侧列表里每条会话都活在自己的房间里。
+       * 原先只监听「当前打开的那个房间」→ 后台会话的 turn_status 收不到
+       * → 左侧只能等 15 秒轮询,用户实测「回复完了还在转很久」。
+       */
+      const type = ev.getType();
+      const content = ev.getContent() as Record<string, unknown> | undefined;
+      /* A:自定义事件类型直接带字段 */
+      let payload: { sessionKey?: unknown; status?: unknown } | undefined;
+      if (type === TURN_STATUS_EVENT) {
+        payload = content as { sessionKey?: unknown; status?: unknown };
+      } else if (type === 'm.room.message') {
+        /* B:兜底 —— 消息体里带 fmode.turn_status */
+        const inner = content?.['fmode.turn_status'];
+        if (inner && typeof inner === 'object') {
+          payload = inner as { sessionKey?: unknown; status?: unknown };
+        }
+      }
+      if (!payload) return;
+      const key = typeof payload.sessionKey === 'string' ? payload.sessionKey : '';
+      const st = typeof payload.status === 'string' ? payload.status : '';
+      if (!key || !st) return;
+      cb(key, st, ev.getSender() ?? '', ev.getRoomId() ?? '');
     };
     c.on('Room.timeline' as never, handler as never);
     return () => {
@@ -1282,5 +1487,31 @@ export class MatrixClientService implements OnDestroy {
 }
 
 /** homeserver 常量(与《API-契约》§1.2 一致;地址真源见 environment.base.ts) */
+/**
+ * 容器推送回合状态用的自定义 Matrix 事件类型(2026-11-12)。
+ *
+ * 与 ✅ reaction 的区别:✅ = 「这条消息发完了」;本事件 = 「这一轮结束了」。
+ */
+export const TURN_STATUS_EVENT = 'com.fmode.turn_status';
+
+/**
+ * 初次 `/sync` 每个房间带多少条历史事件(2026-11-12 实测调优)。
+ *
+ * ## 为什么是 5 而不是 50
+ *
+ * 一房间一会话后房间数持续增长(实测 184 个),每次 `/sync` 拉的量是
+ * `房间数 × limit`。实测对比(同一账号、同一时刻):
+ *
+ * | limit | 耗时   | 事件数 |
+ * |-------|--------|--------|
+ * | 50    | 1593ms | 4010   |
+ * | 5     | **451ms** | 920 |
+ *
+ * 降到 5 后首屏快 3.5 倍。**不会丢历史** ——
+ * 打开会话时 `ChatService.backfillSessionTimeline()` 会按会话回溯补齐,
+ * 命中即停,多数情况 1~2 轮。
+ */
+export const INITIAL_SYNC_LIMIT = 5;
+
 export const MATRIX_HS = environment.endpoints.matrixHs;
 export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;

+ 52 - 0
src/app/core/session.service.spec.ts

@@ -78,4 +78,56 @@ describe('SessionService', () => {
     expect(s.token()).toBe('r:abc');
     expect(s.me()?.name).toBe('改名了');
   });
+
+  /*
+   * session 过期(2026-11-12)。
+   *
+   * 密码登录的账号带 `expiresAt`(实测有效期 1 年)。
+   * 过期后必须能本地判出来,否则用户会看到一堆 401 却不知道原因。
+   */
+  describe('过期时间', () => {
+    it('未过期 → 仍算已登录', () => {
+      const s = make();
+      const future = new Date(Date.now() + 86_400_000).toISOString();
+      s.setSession('r:abc', ME, future);
+      expect(s.isExpired()).toBeFalse();
+      expect(s.isLoggedIn()).toBeTrue();
+    });
+
+    it('已过期 → isExpired=true 且 isLoggedIn=false', () => {
+      const s = make();
+      const past = new Date(Date.now() - 1000).toISOString();
+      s.setSession('r:abc', ME, past);
+      expect(s.isExpired()).toBeTrue();
+      expect(s.isLoggedIn()).toBeFalse();
+    });
+
+    it('🔴 不传 expiresAt → 视为不过期(老登录态不能被踢下线)', () => {
+      const s = make();
+      s.setSession('r:abc', ME);
+      expect(s.expiresAt()).toBeNull();
+      expect(s.isExpired()).toBeFalse();
+      expect(s.isLoggedIn()).toBeTrue();
+    });
+
+    it('过期时间跨实例持久化(刷新后仍生效)', () => {
+      const past = new Date(Date.now() - 1000).toISOString();
+      make().setSession('r:abc', ME, past);
+      expect(make().isExpired()).toBeTrue();
+    });
+
+    it('logout 一并清除过期时间', () => {
+      const s = make();
+      s.setSession('r:abc', ME, new Date(Date.now() + 86_400_000).toISOString());
+      s.logout();
+      expect(s.expiresAt()).toBeNull();
+      expect(make().isExpired()).toBeFalse();
+    });
+
+    it('格式非法的过期时间 → 不误判为过期', () => {
+      const s = make();
+      s.setSession('r:abc', ME, 'not-a-date');
+      expect(s.isExpired()).toBeFalse();
+    });
+  });
 });

+ 52 - 2
src/app/core/session.service.ts

@@ -3,6 +3,8 @@ import { Me } from './api/models';
 
 const TOKEN_KEY = 'fmode_session_token';
 const ME_KEY = 'fmode_me';
+/** session 过期时间(ISO 字符串) */
+const EXPIRES_KEY = 'fmode_session_expires_at';
 
 /**
  * 会话状态(登录态 / 身份)—— 全项目唯一持有者
@@ -16,16 +18,54 @@ const ME_KEY = 'fmode_me';
 export class SessionService {
   private readonly _token = signal<string | null>(this.read(TOKEN_KEY));
   private readonly _me = signal<Me | null>(this.readJson<Me>(ME_KEY));
+  /*
+   * session 过期时间(2026-11-12)。
+   * 用 signal 而非普通字段:过期与否会影响 `isLoggedIn`,界面要能随之重算。
+   */
+  private readonly _expiresAt = signal<string | null>(this.read(EXPIRES_KEY));
 
   readonly token = this._token.asReadonly();
   readonly me = this._me.asReadonly();
-  readonly isLoggedIn = computed(() => !!this._token() && !!this._me());
+  readonly expiresAt = this._expiresAt.asReadonly();
+
+  /**
+   * 本地是否已过期。
+   *
+   * 🔴 只做**快速判断**,不是唯一真相:真正的失效以服务端 401 为准。
+   *    拿不到 `expiresAt`(老登录态 / 接口不可用)时视为「未过期」,
+   *    保持原行为,不把已有用户踢下线。
+   */
+  readonly isExpired = computed(() => {
+    const e = this._expiresAt();
+    if (!e) return false;
+    const t = Date.parse(e);
+    return Number.isFinite(t) && Date.now() >= t;
+  });
+
+  readonly isLoggedIn = computed(() => !!this._token() && !!this._me() && !this.isExpired());
 
-  setSession(token: string, me: Me): void {
+  /**
+   * 写入登录态。
+   *
+   * @param expiresAt 该 session 的过期时间(ISO)。**可选** ——
+   *                  手机验证码 / Token 直登拿不到时传空,
+   *                  退化为「不过期」,与改动前行为一致。
+   */
+  setSession(token: string, me: Me, expiresAt?: string | null): void {
     this._token.set(token);
     this._me.set(me);
+    this._expiresAt.set(expiresAt ?? null);
     this.write(TOKEN_KEY, token);
     this.writeJson(ME_KEY, me);
+    if (expiresAt) this.write(EXPIRES_KEY, expiresAt);
+    else this.remove(EXPIRES_KEY);
+  }
+
+  /** 单独更新过期时间(登录后异步取到时补写) */
+  setExpiresAt(expiresAt: string | null): void {
+    this._expiresAt.set(expiresAt ?? null);
+    if (expiresAt) this.write(EXPIRES_KEY, expiresAt);
+    else this.remove(EXPIRES_KEY);
   }
 
   setMe(me: Me): void {
@@ -65,9 +105,11 @@ export class SessionService {
   logout(): void {
     this._token.set(null);
     this._me.set(null);
+    this._expiresAt.set(null);
     try {
       localStorage.removeItem(TOKEN_KEY);
       localStorage.removeItem(ME_KEY);
+      localStorage.removeItem(EXPIRES_KEY);
     } catch {
       /* 隐私模式下 localStorage 不可用,忽略 */
     }
@@ -75,6 +117,14 @@ export class SessionService {
 
   // ---------- 持久化(localStorage 不可用时静默降级为内存态) ----------
 
+  private remove(key: string): void {
+    try {
+      localStorage.removeItem(key);
+    } catch {
+      /* 隐私模式 / 配额:忽略 */
+    }
+  }
+
   private read(key: string): string | null {
     try {
       return localStorage.getItem(key);

+ 175 - 13
src/app/core/task-pulse.service.ts

@@ -33,16 +33,21 @@ const FRESH_MS = HEARTBEAT_STALE_SEC * 1000;
 const REPLY_QUIET_MS = QUIET_MS;
 
 /**
- * 本地「刚发出」标记的有效期(2026-11-11)。
+ * 本地「刚发出」标记的有效期。
  *
  * 它只是**过渡用**:覆盖「用户点发送 → 容器上报 running」之间的空档。
- * 一旦超过这个时间,就该由容器上报的真实状态说了算 ——
- * 否则某个界面忘了清理(比如左侧会话树不走 `describe()`)就会永久转圈。
+ * 超过这个时间就完全交给容器的真实上报。
  *
- * 取 60 秒:足够覆盖容器首轮响应的最慢情况(DNS 修复后实测首字 2~9 秒),
- * 又不至于让误留的标记长期作妖。
+ * 🔴 2026-11-12 由 60 秒改为 **15 秒**(依据实测,不是拍脑袋):
+ *    `agent-node-lj-test` 实测 **5 秒**上报 running、**7 秒**变 idle。
+ *    容器的上报已经足够快,本地标记只需撑过这 5 秒;
+ *    留 60 秒的后果是「容器早就说完成了,左侧还要空转近一分钟」
+ *    (用户多次实测反馈)。
+ *
+ *    留 15 秒(3 倍余量)既覆盖偶发的上报延迟,又不会长时空转。
+ *    若将来容器上报变慢,应先修容器的上报,而不是把这个值调大。
  */
-const SESSION_LOCAL_START_TTL_MS = 60_000;
+const SESSION_LOCAL_START_TTL_MS = 15_000;
 
 /** 超过这么久的事件视为「历史重放」,不据此判断"正在回复" */
 const STALE_MSG_MS = 60_000;
@@ -115,6 +120,14 @@ export class TaskPulseService {
 
   /** 最近一条消息到达时刻(agentId → 毫秒),用于「正在回复」判定 */
   private readonly lastMsgAt = new Map<string, number>();
+  /**
+   * **会话级**「最后一条 agent 消息的时刻」(sessionId → 毫秒)。
+   *
+   * 用途:左侧列表判断「这条会话到底在不在输出」。容器不挂 thread 的 DM 里,
+   * `currentSessions` 按 roomId 匹配,同一房间下多条会话会互相盖章(H-07 的代价),
+   * 这时用「本会话最后一条 agent 消息」比容器上报更贴近用户看到的画面。
+   */
+  private readonly agentMsgAtBySession = new Map<string, number>();
   /** 会话里最后一条消息(含内容),用于识别新消息到达 */
   private lastSeenMsgId = '';
   /** 首次观测需要先建立基线,不能把「第一次看到 idle」当成「刚完成」 */
@@ -201,6 +214,11 @@ export class TaskPulseService {
     const next = new Map(this._localSessionStart());
     next.set(sessionId, Date.now());
     this._localSessionStart.set(next);
+    /*
+     * 🔴 用户又发了新消息 → 上一轮的「已结束」结论作废(2026-11-12)。
+     *    不清的话,新一轮会立刻被 `sessionEndedAfterSend()` 判成「已结束」→ 一下都不转。
+     */
+    this.clearSessionEnded(sessionId);
   }
 
   /**
@@ -221,6 +239,85 @@ export class TaskPulseService {
     return now - at < SESSION_LOCAL_START_TTL_MS ? at : undefined;
   };
 
+  /**
+   * 记录「本会话最后一条 agent 消息的时刻」(实时到达时才记)。
+   *
+   * 左侧列表据此判断「它还在输出」。`noteMessage()` 是**容器级**的,
+   * 多会话时会把整个容器的活跃算到每条会话头上;这里的粒度是会话。
+   */
+  noteSessionAgentMessage(sessionId: string, at = Date.now()): void {
+    if (!sessionId) return;
+    this.agentMsgAtBySession.set(sessionId, at);
+    /*
+     * 同步写一份 signal 版本:普通 Map 变化不会触发 Angular 重算,
+     * 左侧转圈要等下一次轮询才发现该停(2026-11-12)。
+     * 注意:它**只用于驱动重算**,不参与「结束」判定 —— 结束必须由容器说了算。
+     */
+    const next = new Map(this._lastAgentMsgAt());
+    next.set(sessionId, at);
+    this._lastAgentMsgAt.set(next);
+  }
+
+  /** 本会话最后一条 agent 消息时刻(signal 版,仅用于驱动界面重算) */
+  private readonly _lastAgentMsgAt = signal<ReadonlyMap<string, number>>(new Map());
+
+  /**
+   * 本会话**是否仍在输出**:最近 `REPLY_QUIET_MS` 内收到过 agent 消息。
+   *
+   * 🔴 这是左侧转圈的第 2 个信号(2026-11-12 用户实测)。
+   *
+   * 「应该是他在输出就转动,没有回复就停止」——
+   * 原实现只认三条:① 当前打开的会话看 ✅ ② 容器 `currentSessions` ③ 本地 60 秒标记。
+   * 后台会话三条都可能晚到或落空,于是转个不停。
+   * 用「最近一条 agent 消息」兜住:只要确实在往外吐字就转,停了口就不再转。
+   */
+  sessionIsWriting(sessionId: string, now = Date.now()): boolean {
+    const at = this.agentMsgAtBySession.get(sessionId);
+    return at !== undefined && now - at < REPLY_QUIET_MS;
+  }
+
+  /**
+   * 「本会话这一轮**已经结束**」的本地时间戳(sessionId → 毫秒)。
+   *
+   * 🔴 来源是容器主动推送的 `com.fmode.turn_status { status: 'idle' }` 事件
+   *    (2026-11-12 方案 2)。它比 15 秒轮询快得多,是左侧停转的**首选依据**。
+   *
+   * 用 signal 而非普通 Map:左侧模板要能随它立刻重算,否则要等下一次轮询。
+   */
+  private readonly _sessionEndedAt = signal<ReadonlyMap<string, number>>(new Map());
+
+  /** 容器说这一轮结束了 → 左侧立即停止转圈(不必等轮询) */
+  markSessionEnded(sessionId: string, at = Date.now()): void {
+    if (!sessionId) return;
+    const next = new Map(this._sessionEndedAt());
+    next.set(sessionId, at);
+    this._sessionEndedAt.set(next);
+  }
+
+  /** 用户又发了新消息 → 作废上一条「已结束」结论 */
+  clearSessionEnded(sessionId: string): void {
+    const cur = this._sessionEndedAt();
+    if (!cur.has(sessionId)) return;
+    const next = new Map(cur);
+    next.delete(sessionId);
+    this._sessionEndedAt.set(next);
+  }
+
+  /**
+   * 这一轮是否**已被容器明确判定结束**,且之后用户没有再发消息。
+   *
+   * 判定:存在结束时间戳,且它**晚于**本地「刚发出」时刻(否则是上一轮的旧结论)。
+   */
+  sessionEndedAfterSend(sessionId: string): boolean {
+    this._sessionEndedAt(); // 建立响应式依赖
+    const endedAt = this._sessionEndedAt().get(sessionId);
+    if (endedAt === undefined) return false;
+    const startedAt = this._localSessionStart().get(sessionId);
+    /* 没有「刚发出」标记(说明本轮不是用户在这台设备发的)→ 结束结论有效 */
+    if (startedAt === undefined) return true;
+    return endedAt >= startedAt;
+  }
+
   /**
    * 容器确认该会话结束后清掉本地标记。
    *
@@ -234,6 +331,40 @@ export class TaskPulseService {
     this._localSessionStart.set(next);
   }
 
+  /**
+   * 容器打了 ✅/❌ → 清掉那一轮的本地「刚发出」标记,让左侧立刻停止转圈。
+   *
+   * ## 解决什么(2026-11-12 用户实测)
+   *
+   * 「左侧的显示状态有问题:我点开他就停止,不点开就转。」
+   * 根因是 `SessionTree.sessionRunningOf()` 只在**当前打开的会话**上才用
+   * reaction 收尾,其余会话只能等容器 `currentSessions` 上报过期,
+   * 外加本地标记有 60 秒 TTL —— 于是后台会话要多转几十秒。
+   *
+   * ## 怎么知道是「哪一轮」
+   *
+   * ✅ 带 `m.relates_to.event_id`,指向容器本轮的最后一条消息。
+   * 调用方(`ChatService`)用它算出这条消息属于哪条会话,传进来即可;
+   * 拿不到(消息不在缓存里)就传 `null`,此时退化为「清全部本地标记」——
+   * 容器既然已经收工,继续转圈一定比「少转一会儿」更糟。
+   *
+   * @param sessionIds 要清的会话 id;`null` = 清全部
+   */
+  clearSessionLocalStartFor(sessionIds: readonly string[] | null): void {
+    const cur = this._localSessionStart();
+    if (!cur.size) return;
+    if (sessionIds === null) {
+      if (!cur.size) return;
+      this._localSessionStart.set(new Map());
+      return;
+    }
+    const hit = sessionIds.filter((id) => cur.has(id));
+    if (!hit.length) return;
+    const next = new Map(cur);
+    for (const id of hit) next.delete(id);
+    this._localSessionStart.set(next);
+  }
+
   /**
    * 是否在忙(供 UI 判定)。
    *
@@ -307,8 +438,13 @@ export class TaskPulseService {
      */
     const sessionAware = sessionId !== undefined && sessionId !== null;
     const hasSessionReport = (agent.currentSessions?.length ?? 0) > 0;
+    /*
+     * 🔴 传本地「刚发出」时刻(2026-11-12):上一轮收尾时写的 `idle` 记录
+     *    比本轮提问还早,不能拿它把刚发出的会话判成空闲。
+     */
+    const localStart = sessionAware ? this.sessionLocalStartAt(sessionId, now) : undefined;
     const mine = sessionAware
-      ? sessionRunning(agent.currentSessions, sessionId, roomId, myUserId, now)
+      ? sessionRunning(agent.currentSessions, sessionId, roomId, myUserId, now, localStart)
       : undefined;
     if (sessionAware && mine === false) {
       /*
@@ -340,12 +476,36 @@ export class TaskPulseService {
      * 理由:消息流是实时的,容器上报是分钟级的;越实时的信号越应该优先。
      */
     if (local && this.isBusy(agent, now)) {
-      return {
-        text: '刚刚开始 · 正在处理你的消息',
-        tone: 'info',
-        active: true,
-        since: local,
-      };
+      /*
+       * 🔴 必须再确认「忙的是**本会话**」(2026-11-12 用户截图实测)。
+       *
+       * `isBusy()` 看的是**容器级** status:容器在跑别的会话时它也为 true。
+       * 于是新建一条空会话,右上角立刻显示「正在处理…」—— 明明什么都没做。
+       *
+       * 这里补一道会话级校验:只有「本会话在跑」或「本会话确实有消息流」
+       * 才认这条本地推测。
+       */
+      /*
+       * 🔴 只有**能判断**时才收紧(2026-11-12)。
+       *
+       * - 没给 sessionId(老调用方 / 非会话页)→ 无从判断,保持原行为;
+       * - 给了 sessionId:容器说本会话**明确空闲** → 不认这条本地推测;
+       *   其余情况(running / 不知道 / 本会话正在吐字)→ 认。
+       *
+       * 这样既消掉「新建空会话就显示正在处理」,又不会误伤「用户刚发出、
+       * 容器还没来得及上报」的正常窗口。
+       */
+      const mineIdle =
+        !!sessionId &&
+        sessionRunning(agent.currentSessions, sessionId, roomId, myUserId, now, local) === false;
+      if (!mineIdle) {
+        return {
+          text: '刚刚开始 · 正在处理你的消息',
+          tone: 'info',
+          active: true,
+          since: local,
+        };
+      }
     }
     /*
      * 🔴 会话页:**只看本会话的进度**(2026-11-11 用户裁决)。
@@ -497,3 +657,5 @@ function fmtDur(ms: number): string {
   const s = sec % 60;
   return s ? `${m} 分 ${s} 秒` : `${m} 分钟`;
 }
+
+

+ 23 - 1
src/app/desktop/pages/chat/chat.ts

@@ -416,6 +416,23 @@ interface ConversationRow {
                     新建对话
                   </ui-button>
                 </div>
+                } @else if (chat.emptyReason() === 'blank') {
+                <!--
+                  空壳会话(2026-11-12 实测):
+                  已翻到房间最早、目录里有这条、但 Matrix 里**一条消息都没有**。
+                  🔴 必须与「暂时取不到」分开说 —— 后者会让用户一直等、以为数据丢了;
+                     也不能显示成新建会话欢迎页(会诱导用户在这里重新开始)。
+                -->
+                <div class="no-session">
+                  <i class="fa-solid fa-inbox" aria-hidden="true"></i>
+                  <h2 class="no-session-title">这条对话没有保存任何消息</h2>
+                  <p class="no-session-sub">
+                    会话记录还在,但服务器上找不到它的任何消息内容(可能创建时就没有发出过消息)。
+                  </p>
+                  <ui-button variant="primary" size="sm" (click)="requestNewSession()">
+                    新建对话
+                  </ui-button>
+                </div>
                 } @else if (chat.emptyReason() === 'unloaded') {
                 <!--
                   有会话、但一条消息也归属不到它(2026-11-09)。
@@ -2961,7 +2978,12 @@ export class DkChatPage implements OnDestroy {
        */
       if (sessionId === 'new') {
         const ws = this.route.snapshot.queryParamMap.get('ws');
-        const created = await this.chat.startBlank(id, ws || null);
+        /*
+         * 🔴 用 `startBlankWithRoom`(2026-11-12):一房间一会话要求
+         *    会话的专属房间**建好之后**再进入页面,否则首条消息会落进共享房间。
+         *    实测建房 0.6~0.7 秒;失败时内部静默降级为共享房间,不阻塞。
+         */
+        const created = await this.chat.startBlankWithRoom(id, ws || null);
         if (created) {
           /*
            * 🔴 建出会话后**立刻返回**,不要继续往下走(2026-11-01 修复)。

+ 15 - 1
src/app/desktop/shell/desktop-shell.ts

@@ -262,7 +262,21 @@ export class DesktopShellComponent implements OnDestroy {
      * 只登录一次、之后一直用本地副本会导致权限变化不生效。
      */
     if (this.session.token()) {
-      void this.session.refreshMe((t) => this.api.fetchMe(t));
+      /*
+       * 🔴 本地 session 已过期 → 直接登出并回登录页(2026-11-12)。
+       *
+       * 密码登录的账号带 `expiresAt`(实测有效期 1 年)。过期后继续用本地副本,
+       * 用户会看到一堆 401 却不知道原因;这里先本地判断,
+       * 省掉一次必然失败的请求。`authGuard` 会自动把未登录用户导向 `/login`。
+       *
+       * 注意:`expiresAt` 只是**快速判断**,真正的失效仍以服务端 401 为准。
+       */
+      if (this.session.isExpired()) {
+        this.session.logout();
+        void this.router.navigateByUrl('/login');
+      } else {
+        void this.session.refreshMe((t) => this.api.fetchMe(t));
+      }
     }
     if (typeof window !== 'undefined') {
       window.addEventListener('resize', () => this.onResize());

+ 12 - 1
src/app/mobile/pages/chat/chat.ts

@@ -182,6 +182,16 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
                 title="该会话已删除"
                 description="这条对话已经被删除,无法再查看或继续发送消息。"
               />
+            } @else if (chat.emptyReason() === 'blank') {
+              <!--
+                空壳会话(2026-11-12):已翻到房间最早、目录里有这条、但一条消息都没有。
+                必须与「暂时取不到」分开说,否则用户会一直等、以为数据丢了。
+              -->
+              <app-m-empty
+                icon="fa-solid fa-inbox"
+                title="这条对话没有保存任何消息"
+                description="会话记录还在,但服务器上找不到它的任何消息内容。"
+              />
             } @else if (chat.emptyReason() === 'unloaded') {
               <!-- 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」 -->
               <app-m-empty
@@ -1382,7 +1392,8 @@ export class MbChatPage implements OnDestroy {
   private async openSession(agentId: string, sessionId?: string): Promise<void> {
     if (sessionId === 'new') {
       const ws = this.route.snapshot.queryParamMap.get('ws');
-      const created = await this.chat.startBlank(agentId, ws || null);
+      /* 一房间一会话:建房完成后再进入(2026-11-12,与桌面端同口径) */
+      const created = await this.chat.startBlankWithRoom(agentId, ws || null);
       if (created) {
         /*
          * 首页输入坞带过来的草稿(2026-11-09):用户先在首页打字、再选人,

+ 80 - 3
src/app/mobile/pages/login/login.ts

@@ -10,12 +10,13 @@ import {
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { FmodeApi } from '../../../core/api/fmode-api';
+import { Me } from '../../../core/api/models';
 import { DeviceService } from '../../../core/device.service';
 import { PaletteService } from '../../../core/palette.service';
 import { SessionService } from '../../../core/session.service';
 import { queryHasOrg } from '../../../core/org.guard';
 
-type Stage = 'phone' | 'code' | 'token';
+type Stage = 'phone' | 'code' | 'token' | 'password';
 
 /**
  * 移动端登录(Demo 02 + SPEC FR-02)—— **真实接口**
@@ -61,8 +62,40 @@ type Stage = 'phone' | 'code' | 'token';
           <button type="button" class="m-btn m-btn--primary" [disabled]="!phoneValid() || busy()" (click)="sendCode()">
             {{ sending() ? '发送中…' : '获取验证码' }}
           </button>
+          <button type="button" class="m-more" (click)="stage.set('password')">使用账号密码登录</button>
           <button type="button" class="m-more" (click)="stage.set('token')">使用 Token 直接登录</button>
         }
+        @case ('password') {
+          <!--
+            账号密码登录(2026-11-12):康师傅账套 27 个账号主要走这里。
+          -->
+          <label class="m-field">
+            <input
+              #userEl
+              type="text"
+              autocomplete="username"
+              placeholder="请输入账号"
+              [value]="username()"
+              aria-label="账号"
+              (input)="onUsername($event)"
+            />
+          </label>
+          <label class="m-field">
+            <input
+              #pwdEl
+              [type]="showPassword() ? 'text' : 'password'"
+              autocomplete="current-password"
+              placeholder="请输入密码"
+              [value]="password()"
+              aria-label="密码"
+              (input)="onPassword($event)"
+            />
+          </label>
+          <button type="button" class="m-btn m-btn--primary" [disabled]="!passwordStageValid() || busy()" (click)="submitPassword()">
+            {{ busy() ? '登录中…' : '登 录' }}
+          </button>
+          <button type="button" class="m-more" (click)="stage.set('phone')">使用手机验证码登录</button>
+        }
         @case ('code') {
           <label class="m-field">
             <span class="m-prefix">+86 {{ maskedPhone() }}</span>
@@ -300,6 +333,9 @@ export class MbLoginPage implements OnDestroy {
 
   protected readonly stage = signal<Stage>('phone');
   protected readonly phone = signal('');
+  protected readonly username = signal('');
+  protected readonly password = signal('');
+  protected readonly showPassword = signal(false);
   protected readonly code = signal('');
   protected readonly token = signal('');
   protected readonly agreed = signal(false);
@@ -309,6 +345,10 @@ export class MbLoginPage implements OnDestroy {
   protected readonly countdown = signal(0);
 
   protected readonly phoneValid = computed(() => /^1\d{10}$/.test(this.phone()));
+  /** 账号密码页的提交条件(2026-11-12) */
+  protected readonly passwordStageValid = computed(
+    () => this.username().trim().length > 0 && this.password().length > 0,
+  );
   protected readonly maskedPhone = computed(() => {
     const p = this.phone();
     return `${p.slice(0, 3)}****${p.slice(7)}`;
@@ -396,8 +436,10 @@ export class MbLoginPage implements OnDestroy {
       const s =
         this.stage() === 'token'
           ? await this.api.loginWithToken(this.token().trim())
-          : await this.api.loginWithMobile(this.phone(), this.code());
-      this.session.setSession(s.token, s.me);
+          : this.stage() === 'password'
+            ? await this.api.loginWithPassword(this.username(), this.password())
+            : await this.api.loginWithMobile(this.phone(), this.code());
+      await this.persistSession(s.token, s.me);
       this.clearTokenFromAddress();
       await this.afterLogin();
     } catch (e) {
@@ -407,6 +449,41 @@ export class MbLoginPage implements OnDestroy {
     }
   }
 
+  /** 账号密码页独立提交(协议勾选约束与手机号页一致) */
+  protected async submitPassword(): Promise<void> {
+    if (this.busy()) return;
+    this.error.set(null);
+    if (!this.passwordStageValid()) {
+      this.error.set('请输入账号和密码');
+      return;
+    }
+    if (!this.agreed()) {
+      this.error.set('请先阅读并同意服务协议和隐私政策');
+      return;
+    }
+    await this.submit();
+  }
+
+  protected onUsername(e: Event): void {
+    this.username.set((e.target as HTMLInputElement).value.trim());
+    this.error.set(null);
+  }
+
+  protected onPassword(e: Event): void {
+    this.password.set((e.target as HTMLInputElement).value);
+    this.error.set(null);
+  }
+
+  /**
+   * 写入登录态并补取 session 过期时间(2026-11-12)。
+   * 取不到不阻断登录(退化为「不过期」)。
+   */
+  private async persistSession(token: string, me: Me): Promise<void> {
+    this.session.setSession(token, me);
+    const expiresAt = await this.api.fetchSessionExpiry(token).catch(() => null);
+    if (expiresAt) this.session.setExpiresAt(expiresAt);
+  }
+
   /** 协议链接:当前无独立协议页,阻止默认跳转而不伪造内容 */
   protected blockLink(e: Event): void {
     e.preventDefault();

+ 7 - 1
src/app/mobile/shell/mobile-shell.ts

@@ -285,7 +285,13 @@ export class MobileShellComponent implements OnDestroy {
      * 失败静默,不打断使用。
      */
     if (this.session.token()) {
-      void this.session.refreshMe((t) => this.api.fetchMe(t));
+      /* 本地 session 已过期 → 登出并回登录页(2026-11-12,与桌面端同口径) */
+      if (this.session.isExpired()) {
+        this.session.logout();
+        void this.router.navigateByUrl('/login');
+      } else {
+        void this.session.refreshMe((t) => this.api.fetchMe(t));
+      }
     }
 
     if (typeof window !== 'undefined') {

+ 115 - 11
src/app/pages/login/login.ts

@@ -3,12 +3,13 @@ import { NgTemplateOutlet } from '@angular/common';
 import { ActivatedRoute, Router } from '@angular/router';
 import { UiAlert, UiButton, UiInput } from '../../ui';
 import { FmodeApi } from '../../core/api/fmode-api';
+import { Me } from '../../core/api/models';
 import { DeviceService } from '../../core/device.service';
 import { PaletteService } from '../../core/palette.service';
 import { SessionService } from '../../core/session.service';
 import { queryHasOrg } from '../../core/org.guard';
 
-type LoginMethod = 'mobile' | 'token';
+type LoginMethod = 'password' | 'mobile' | 'token';
 
 /**
  * 登录页(SPEC FR-02)—— 两端共用同一组件,形态随端自适应
@@ -126,6 +127,15 @@ type LoginMethod = 'mobile' | 'token';
       </header>
 
       <div class="mode-tabs">
+        <button
+          type="button"
+          class="mode-tab"
+          [class.active]="method() === 'password'"
+          [attr.aria-pressed]="method() === 'password'"
+          (click)="switchMethod('password')"
+        >
+          账号登录
+        </button>
         <button
           type="button"
           class="mode-tab"
@@ -146,6 +156,32 @@ type LoginMethod = 'mobile' | 'token';
         </button>
       </div>
 
+      @if (method() === 'password') {
+        <!--
+          账号密码登录(2026-11-12):康师傅账套 27 个账号
+          (用户名=姓名拼音,密码统一发放)主要走这里。
+        -->
+        <label class="field">
+          <span class="field-label">账号</span>
+          <ui-input
+            placeholder="请输入账号"
+            [value]="username()"
+            [invalid]="!!error() && !usernameValid()"
+            (valueChange)="onUsername($event)"
+          />
+        </label>
+        <label class="field">
+          <span class="field-label">密码</span>
+          <ui-input
+            [type]="showPassword() ? 'text' : 'password'"
+            placeholder="请输入密码"
+            [value]="password()"
+            [invalid]="!!error() && !passwordValid()"
+            (valueChange)="onPassword($event)"
+          />
+        </label>
+      }
+
       @if (method() === 'mobile') {
         <label class="field">
           <span class="field-label">手机号</span>
@@ -172,7 +208,15 @@ type LoginMethod = 'mobile' | 'token';
           </div>
           <small class="field-hint">手机号仅用于登录验证</small>
         </label>
-      } @else {
+      } @else if (method() === 'token') {
+        <!--
+          🔴 必须是显式 else if(2026-11-12 修复):
+          原来写的是裸 else,它只与上一个「method === 'mobile'」配对 ——
+          于是选中「账号登录」时也会落进这个分支,把 SessionToken 输入框露出来。
+
+          ⚠️ 模板注释里**不要写反引号**:它会被当成模板字符串起始,吞掉后续内容,
+             报错却指向好几行之后(本次踩坑:报 "Unexpected ;" 在 534 行)。
+        -->
         <label class="field">
           <span class="field-label">Fmode 平台 SessionToken</span>
           <ui-input
@@ -242,7 +286,12 @@ type LoginMethod = 'mobile' | 'token';
       /* ============ 桌面:左落地 + 右登录卡 ============ */
       .split {
         display: grid;
-        grid-template-columns: minmax(0, 1.08fr) minmax(420px, 0.92fr);
+        /*
+         * 🔴 右侧登录栏收窄(2026-11-12 用户反馈「压缩一下整体宽度」):
+         *    原 420px 下限让卡片显得过宽、留白过多。收到 380px,
+         *    并在下方给卡片本身加 max-width,避免超宽屏被拉满。
+         */
+        grid-template-columns: minmax(0, 1.15fr) minmax(380px, 0.85fr);
         min-height: 100dvh;
       }
 
@@ -407,6 +456,8 @@ type LoginMethod = 'mobile' | 'token';
       }
       .card {
         width: 100%;
+        /* 卡片本体最宽 400px(2026-11-12):再宽输入框会显得空旷 */
+        max-width: 400px;
         display: grid;
         gap: var(--sp-5);
         padding: var(--sp-6);
@@ -537,7 +588,12 @@ type LoginMethod = 'mobile' | 'token';
 
       .mode-tabs {
         display: grid;
-        grid-template-columns: 1fr 1fr;
+        /*
+         * 🔴 三种登录方式必须**横排一行**(2026-11-12 用户截图反馈):
+         *    两列时第三个 tab 会被挤到第二行,看起来像「上下布局」。
+         *    这里固定三等分,窄屏时靠字号与内边距压缩,不换行。
+         */
+        grid-template-columns: repeat(3, minmax(0, 1fr));
         gap: var(--sp-1);
         padding: var(--sp-1);
         border: 1px solid var(--edge);
@@ -551,6 +607,13 @@ type LoginMethod = 'mobile' | 'token';
         background: transparent;
         color: var(--ink-2);
         font-size: var(--fs-13);
+        /*
+         * 🔴 三等分后文字更窄,允许折行而不是溢出(2026-11-12);
+         *    同时收紧内边距,保证「验证码登录」这类 5 字标签不挤出按钮。
+         */
+        padding: 0 var(--sp-1);
+        line-height: 1.2;
+        white-space: normal;
         cursor: pointer;
       }
       .mode-tab.active {
@@ -671,7 +734,11 @@ export class LoginComponent {
   private readonly router = inject(Router);
   private readonly route = inject(ActivatedRoute);
 
-  readonly method = signal<LoginMethod>('mobile');
+  /** 默认「账号登录」(2026-11-12):康师傅等账套的批量账号主要走这里 */
+  readonly method = signal<LoginMethod>('password');
+  readonly username = signal('');
+  readonly password = signal('');
+  readonly showPassword = signal(false);
   readonly mobile = signal('');
   readonly code = signal('');
   readonly token = signal('');
@@ -695,6 +762,8 @@ export class LoginComponent {
   readonly mobileValid = computed(() => /^1\d{10}$/.test(this.mobile()));
   readonly codeValid = computed(() => /^\d{6}$/.test(this.code()));
   readonly tokenValid = computed(() => this.token().trim().length > 0);
+  readonly usernameValid = computed(() => this.username().trim().length > 0);
+  readonly passwordValid = computed(() => this.password().length > 0);
   readonly canSendCode = computed(
     () => this.mobileValid() && this.countdown() === 0 && !this.busy() && !this.sendingCode(),
   );
@@ -703,7 +772,9 @@ export class LoginComponent {
       ? false
       : this.method() === 'mobile'
         ? this.mobileValid() && this.codeValid()
-        : this.tokenValid(),
+        : this.method() === 'password'
+          ? this.usernameValid() && this.passwordValid()
+          : this.tokenValid(),
   );
 
   /** 落地页内容(参照第一版项目的价值主张) */
@@ -756,7 +827,7 @@ export class LoginComponent {
     this.busy.set(true);
     try {
       const s = await this.api.loginWithToken(decodeURIComponent(urlToken));
-      this.session.setSession(s.token, s.me);
+      await this.persistSession(s.token, s.me);
       this.clearTokenFromAddress();
       await this.afterLogin();
     } catch (e) {
@@ -784,6 +855,16 @@ export class LoginComponent {
     this.error.set(null);
   }
 
+  onUsername(v: unknown): void {
+    this.username.set(String(v).trim());
+    this.error.set(null);
+  }
+
+  onPassword(v: unknown): void {
+    this.password.set(String(v));
+    this.error.set(null);
+  }
+
   onCode(v: unknown): void {
     this.code.set(String(v).replace(/\D/g, '').slice(0, 6));
     this.error.set(null);
@@ -839,6 +920,15 @@ export class LoginComponent {
         this.error.set('请输入 6 位验证码');
         return;
       }
+    } else if (this.method() === 'password') {
+      if (!this.username().trim()) {
+        this.error.set('请输入账号');
+        return;
+      }
+      if (!this.password()) {
+        this.error.set('请输入密码');
+        return;
+      }
     } else if (!this.token()) {
       this.error.set('请输入访问令牌');
       return;
@@ -849,15 +939,16 @@ export class LoginComponent {
       const s =
         this.method() === 'mobile'
           ? await this.api.loginWithMobile(this.mobile(), this.code())
-          : await this.api.loginWithToken(this.token());
-      this.session.setSession(s.token, s.me);
+          : this.method() === 'password'
+            ? await this.api.loginWithPassword(this.username(), this.password())
+            : await this.api.loginWithToken(this.token());
+      await this.persistSession(s.token, s.me);
       this.clearTokenFromAddress();
       await this.afterLogin();
     } catch (e) {
       this.error.set(this.messageOf(e));
     } finally {
-      this.busy.set(false);
-    }
+      this.busy.set(false);    }
   }
 
   /** AC-02.3:按 redirect 回跳,且 replaceUrl */
@@ -889,4 +980,17 @@ export class LoginComponent {
   private messageOf(e: unknown): string {
     return e instanceof Error && e.message ? e.message : '登录失败,请稍后重试';
   }
+
+  /**
+   * 写入登录态,并取一次 session 过期时间(2026-11-12)。
+   *
+   * 🔴 过期时间取不到**不能**阻断登录:`fetchSessionExpiry` 内部已 try/catch
+   *    并返回 null,此时退化为「不过期」,与改动前行为一致。
+   *    先 `setSession` 再补写过期时间,避免多等一次网络才进应用。
+   */
+  private async persistSession(token: string, me: Me): Promise<void> {
+    this.session.setSession(token, me);
+    const expiresAt = await this.api.fetchSessionExpiry(token).catch(() => null);
+    if (expiresAt) this.session.setExpiresAt(expiresAt);
+  }
 }