Procházet zdrojové kódy

feat(chat): 本轮结束信号定稿(✅ reaction)+ 长任务状态如实显示 + 容器接入文档

## 根因(/sync 实测,非推测)
容器整轮不发可靠结束信号,App 只能靠静默窗口猜:
  +14.8s typing=false   ← 早于正文 0.6 秒
  +15.3s 消息「3 乘以 7 等于 21。」
  +15.4s reaction ✅    ← 晚于正文 ✓
=> 改用 ✅ reaction(Hermes 内建,时序正确,不依赖插件改造)

## App 侧
- assistant-turn: 结束判据改为「只看容器信号」——有步骤+无信号则一直输出中;
  超时只弹提示不改状态;isTurnStalled 去掉 !live 限制(长任务卡死也要能提示)
- chat.service: 新增 onTurnReaction 订阅,按消息 id 认领 + 校验 sender(群聊多容器必需)
- matrix-client: onTurnReaction 返回 sender
- task-pulse: describe() 加 sessionId,会话页只认本会话,没在跑显示「在线」
- session-tree: 会话在跑时圆点→旋转 spinner
- chat(双端): 右上角状态改为消费回合 live(与卡片同源,修「右上角正在回复 vs 卡片已完成」)
- normalize-md: 单个 ~ → ~,修「8~16℃ 被判成 ~~删除线~~」整段划线

## 口径变更
- 超时 180s → 300s:容器实测跑过 3 分钟以上;且超时已不再改状态,只决定提示时机
- 「保持 90 秒」→「保持到收到结束信号」(用户裁决)

## 文档
- 容器接入完整指南 v2(十节,逐条标文件:行号依据)
- 提示词-容器接入 Fmode App(可直接发给容器自查)
- 容器接入规范提示词 §7.5 按实测重写:用 reaction,不用 typing

## 验证
tsc ✅ · 1653 条测试全过 ✅ · 生产构建 ✅ · 已部署线上(main-KB7OTZPJ.js)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct před 1 hodinou
rodič
revize
8e282e9218

+ 155 - 0
docs/00-tracking/2026-11-11-修改留痕-输出中残留与结束信号.md

@@ -0,0 +1,155 @@
+# 修改留痕:会话「一直显示输出中」与结束信号缺失(2026-11-11)
+
+| 字段 | 值 |
+|---|---|
+| 现象 | 正文已完整输出(底部时间戳 17:07),头部仍挂「运行了 1 步 · Searching the web… 39s 输出中」 |
+| 用户体感 | 一共 20 秒的活,读数涨到 50 多秒才变「已完成」 |
+| 结论 | **两端都有问题**:容器端不发任何结束信号;App 端只能靠静默窗口猜,且读数口径在结束时倒退 |
+| 涉及 | 容器 `agent-node-lj-test`、`core/assistant-turn.ts`、`core/matrix-client.service.ts`、`core/chat.service.ts` |
+
+---
+
+## 一、取证(实测,非推断)
+
+### 1.1 实时事件流监听
+
+脚本 `scripts/.probe-typing.mjs` 新建房间发一句「1+1 等于几」,逐条打事件:
+
+```
+[+1.5s]  m.room.message  sender=用户   KEYS=["body","m.mentions","msgtype"]
+[+2.3s]  m.reaction      sender=容器   "👀"
+[+16.8s] m.room.message  sender=容器   KEYS=["body","m.relates_to","msgtype"] body="1+1=2。"
+[+16.9s] m.reaction      sender=容器   "✅"
+[+21.9s] m.room.redaction sender=容器
+END. 末条消息距今 105.6s
+```
+
+**关键:整轮 105 秒里 `m.typing` 事件出现 0 次。**
+
+容器回复内容 16.8 秒就发完了,但 App 端要到「最后一条消息后 25 秒(无步骤)/ 60 秒(有步骤)」才认为结束。
+
+### 1.2 容器自述(`ask-agent.mjs` 取证原文)
+
+| 问题 | 容器回答(摘要) |
+|---|---|
+| ✅ 从哪来 | `adapter.py:2331 on_processing_complete()` → `_send_reaction("✅"/"❌")`;**每轮都发**,`CANCELLED` 不发 |
+| 之后做什么 | 只延时 5 秒撤销 👀(`_reaction_redaction_delay_seconds=5.0`)。**不写 status=idle**,idle 由 `plugin-active-reporter` 在 `post_llm_call` 单独发 |
+| 能否加 content 自定义键 | **不能**。`adapter.py` 位于 `/opt/hermes`,`test -w` → `ADAPTER_NOT_writable`,内核层面只读,不在 `HERMES_WRITE_SAFE_ROOT=/opt/data` 内 |
+| 能否发 typing | **代码有**(`send_typing`/`stop_typing`,30 秒刷新),但本轮实测没发。闸门在 `_start_typing_refresh` |
+| 可行的改造点 | **`/opt/data/plugins/` 可写** → 写插件注册 `pre_llm_call`/`post_llm_call` 调 `adapter.send_typing`/`stop_typing`,或 monkeypatch `_build_text_message_content` |
+
+### 1.3 App 侧现状
+
+`core/assistant-turn.ts`:
+
+```ts
+const typingAlive = src.typing && idleFor < quietMs;          // quietMs = 25s
+const graceMs = hasSteps ? Math.max(quietMs, STALL_MS) : quietMs;  // STALL_MS = 60s
+const live = !closedBlocks && !waitingTooLong
+             && (awaitingFirstReply || typingAlive || idleFor < graceMs);
+```
+
+- 有工具步骤 → 宽限到 **60 秒**;这就是「20 秒的活显示到 50 多秒」的来源
+- 读数 `elapsedSeconds()` 进行中用 `now - startAt`、结束时切到 `updatedAt - startAt` → **结束瞬间读数倒退**
+
+### 1.4 线上版本核对
+
+拉取 `https://ai.fmode.cn/main-G7IFQPNG.js` 及全部 21 个 chunk,`fmode.kind` 出现 **0 次** → **线上部署早于提交 `bbcdaca`**。用户反馈「版本不对」成立,线上构建需要重新发布。
+
+---
+
+## 二、修复方案(用户裁决 2026-11-11)
+
+| 端 | 做法 |
+|---|---|
+| 容器 | **插件发 `m.typing=false`**:`pre_llm_call` → `send_typing(true)`;`post_llm_call` → `stop_typing()`。纯插件层,不碰只读镜像 |
+| App | **以信号为主 + 保留兜底**:收到 `typing=false` 立即定格;无信号(老容器)继续走静默窗口;同时修读数倒退 |
+
+---
+
+## 三、实施记录
+
+### 3.1 App 侧(已完成)
+
+| 文件 | 改动 |
+|---|---|
+| `core/assistant-turn.ts` | `BlockSource.turnEnded` / `TurnBlockView.turnEnded`;`live` 判定加入 `!turnEnded`;`elapsedSeconds()` 有信号时定格末条消息 |
+| `core/chat.service.ts` | 新增 `turnEndedAt` / `turnEndedAfter` 信号;`onTyping` 收到 `false` 时记下「此刻末条 agent 消息 id」;三处会话切换点重置 |
+| `core/task-pulse.service.ts` | `describe()` 增加 `sessionId` 参数;**会话页只看本会话进度**,本会话没在跑 → 「在线」 |
+| `desktop/pages/chat/chat.ts` · `mobile/pages/chat/chat.ts` | 传入 `turnEndedAfter` / `sessionId` |
+| `assistant-turn.spec.ts` · `task-pulse.service.spec.ts` | 新增 8 条用例(结束信号 4 + 会话维度 4) |
+
+**关键设计:按消息 id 认领,不按时间戳比较**
+
+原因:消息 `ts` 来自 Matrix 服务端(`origin_server_ts`),信号记的是本地时钟,
+两者差 1~2 秒是常态。服务端稍快时「信号 ≥ 末条消息」永远不成立 → 信号彻底失效。
+改为「信号到达时记下末条 agent 消息 id,回合判定时比对 id」——同一份数据内部比较,
+不跨时钟。
+
+**验证**:`tsc` ✅ · `ng test` **1641/1641** ✅ · `npm run build` ✅
+
+### 3.2 容器侧(进行中)
+
+新建 `/opt/data/plugins/plugin-turn-end/`:
+- `pre_llm_call` → PUT `m.typing` true
+- `post_llm_call` → PUT `m.typing` false(本轮结束信号)
+
+**实测发现的问题(重要)**:
+
+1. **`m.typing` 是 ephemeral 事件,不写 timeline** —— 第一轮用 `/messages` 读,
+   结论「0 条事件」是错的。必须走 `/sync` 的 `RoomMember.typing` 才能看到。
+2. **Hermes adapter 自带 typing 循环**(`_start_typing_refresh` → `_keep_typing`),
+   `finally` 里 `stop_typing` 会在消息 send **之前**发出 `false`。
+   实测 `typing=false` 比正文早 1.6 秒 —— 若直接采信,会出现「正文还没到就显示已完成」。
+3. 结论:**必须关闭 adapter 自带 typing**(`typing_indicator: false`),
+   只保留插件一个信号源,时序才可控。
+
+### 3.3 第二轮改动(用户第二轮反馈后,2026-11-11 晚)
+
+用户反馈四点,全部落地:
+
+| # | 问题 | 修法 | 位置 |
+|---|---|---|---|
+| 1 | 右上角状态显示别的会话进度 | `describe()` 加 `sessionId`;容器有会话级上报时只认本会话,没在跑 → 「在线」 | `core/task-pulse.service.ts` |
+| 2 | 右上角要加载动画 | 小圆点脉动 → **旋转 spinner 环**(`task-spin`,只动画 transform,响应 reduced-motion) | `desktop/pages/chat/chat.ts` |
+| 3 | 左侧列表要加载图标 | `sess-dot` 在**本会话正在跑**时变旋转环(`sess-spin`),颜色沿用容器 tone | `components/domain/session-tree/session-tree.ts` |
+| 4 | 先显示「已完成」、很久后正文才出来 | 新增 `heldOpenByNoSignal`:**有过程步骤 + 无结束信号** → 保持「输出中」到 90 秒兜底 | `core/assistant-turn.ts` |
+| 5 | 正文大量横线 | `normalize-md` 丢弃**孤立分隔线**(`---`/`***`/`___`/`- - -`);不误伤 `8-16℃`、`- 列表项`,代码块内保留 | `components/preview/md-render/normalize-md.ts` |
+
+**新增结束信号源:容器 ✅/❌ reaction**
+
+实测容器的 `on_processing_complete()` 每轮必发 ✅(`CANCELLED` 除外),
+由 Hermes 内建行为产生、不依赖插件,比 `m.typing` 更可靠。
+新增 `matrix-client.service.onTurnReaction()`,与 typing 并列作为结束信号。
+
+**口径变更(有意的行为调整)**:
+
+`assistant-turn.spec.ts` 原用例「静止 70 秒 → 判已结束」改为「静止 70 秒 + 无信号 → 仍算进行中」。
+理由:这正是用户抱怨的「先完成后输出」,70 秒静默不足以证明容器结束。
+
+### 3.4 多会话并发实测(2026-11-11)
+
+脚本 `scripts/test-multi-session.mjs`:同一容器开两个会话并发提问。
+
+| 检查项 | 结果 |
+|---|---|
+| 回复串台 | ✅ **无串台**(A 问西湖、B 问一线城市,各回各的) |
+| typing 按会话隔离 | ✅ A: +11.9s true → +27.5s false;B: +17.6s → +29.5s |
+| 消息完整性 | ✅ 各 1 条,timeline 一致 |
+| 容器 `currentSessions` 上报 | ❌ **返回 undefined**,会话级状态没上报 |
+
+**结论:多会话并发安全**,不会互相干扰。
+唯一问题是容器没上报 `extra.currentSessions`,导致 App 拿不到会话级运行态
+—— 这也是「右上角显示别的会话进度」的深层原因。
+
+> ⚠️ 首次运行时脚本误报「B 里出现 A 的内容」,实为检测逻辑缺陷:
+> B 自己写了「杭州 — 西湖烟雨配互联网大厂」,属正常内容。已核实为**误报**。
+
+### 3.5 待办
+
+- [ ] 容器关掉 adapter 自带 typing、重启、实测(App 侧用 `/sync` 独立回读验证)
+- [ ] **容器上报 `extra.currentSessions`**(当前返回 undefined,会话级状态缺失)
+- [ ] 线上部署:当前 `ai.fmode.cn` 产物早于 `bbcdaca`,需重新发布
+- [ ] 把 `m.typing` ephemeral 这个坑写进《容器接入规范提示词》(已完成)
+
+

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

@@ -7748,3 +7748,67 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 这次如果我轻信,就会带着一个不存在的功能去发布和测试。
 
 ---
+
+## 2026-11-11 19:00-20:00 · 结束信号方案定稿 + 多会话实测 + 首次正式部署
+
+**动作**:解决「正文早已输出完,界面还挂输出中/先显示已完成」;统一双端状态;首次部署到线上
+
+### 一、根因(实测定稿,非推测)
+
+`/sync` 回读 lj-test 的真实事件流:
+
+```
++0.8s   reaction 👀
++6.7s   typing=true
++14.8s  typing=false        ← 早于正文 0.6 秒 ✗
++15.3s  消息「3 乘以 7 等于 21。」
++15.4s  reaction ✅         ← 晚于正文 ✓
+```
+
+- **typing=false 时序天然是反的**(在消息发送前由 finally 发出),不能用
+- **✅ reaction 时序正确**(`on_processing_complete()` 在发送后发)
+- 三场景实测:普通/多步长任务/并发,✅ 全部晚 0.0~0.1s,中途无多余 ✅
+
+### 二、App 侧改动
+
+| 文件 | 改动 |
+|---|---|
+| `core/assistant-turn.ts` | 结束判据改为「**只看容器信号**」:有步骤+无信号→一直输出中;超时只弹提示不改状态;`isTurnStalled` 去掉 `!live` 限制 |
+| `core/chat.service.ts` | 新增 `onTurnReaction` 订阅(按消息 id 认领 + **校验 sender**,群聊多容器必需) |
+| `core/matrix-client.service.ts` | `onTurnReaction()` 返回 sender |
+| `core/task-pulse.service.ts` | `describe()` 加 sessionId,会话页只认本会话 |
+| `components/domain/session-tree` | 会话在跑时圆点→旋转 spinner |
+| `preview/md-render/normalize-md.ts` | 单个 `~` → `&#126;`,防止 `8~16℃` 被解析成删除线 |
+| `desktop/pages/chat/chat.ts` | 右上角状态改为消费回合 `live`(与卡片同源);spinner 动画 |
+
+### 三、用户裁决变更
+
+- 「保持到 90 秒」→ **「应该保持到会话结束」**:90 秒仍是拍脑袋,改为有结束信号才结束
+- 超时 180s → **300s**:容器实测跑过 3 分钟以上,且超时已不再改状态,只弹提示
+
+### 四、多会话并发实测(`scripts/test-multi-session.mjs`)
+
+| 项 | 结果 |
+|---|---|
+| 回复串台 | ✅ 无 |
+| typing 按会话隔离 | ✅ 各发各的 |
+| 容器 `currentSessions` | ❌ **返回 undefined**(容器只在 turn 时写,心跳不带) |
+
+### 五、容器侧
+
+- `plugin-turn-end` 已禁用(typing 方案作废)
+- `typing_indicator` 恢复 true
+- 容器代码取证指出:`/opt/hermes` 是 **root 属主无写权限**(非只读挂载),`/opt/data` 可写
+
+### 六、首次正式部署
+
+- `obsutil` 凭据来自用户提供的 `deploy.ps1`
+- 桶内历史遗留 3379 文件 / 3.40GB,**已完整备份**到 `/tmp/rollback-backup/current-live/`
+- 清空后上传 91 文件(1 个网络超时已重传)
+- 线上版本 `main-KB7OTZPJ.js`,与本地一致
+
+### 教训
+
+1. **容器说我错了,它是对的**:我根据 `cat -A` 截断输出判断 homeserver 为空,容器指出 `.env` 里值存在。撤回。
+2. **我的验证方法错了两次**:用 `/messages` 查 `m.typing`(ephemeral 事件不进 timeline);以为「横线」是 `<hr>`(实际是 `~~删除线~~`)。
+3. **用户两次纠正我的设计**:90 秒兜底 → 保持到会话结束;超时不该改状态。

+ 353 - 0
docs/03-interface/容器接入完整指南-v2-20261111.md

@@ -0,0 +1,353 @@
+# 容器接入 Fmode App 完整指南(v2.0 · 2026-11-11 实测定稿)
+
+> **这份文档给谁看**:要把自己的容器接入 Fmode App 的人 / AI。
+>
+> **编写依据**:全部来自 `agent-node-lj-test` 的**逐行代码取证**(每个结论都标了 `文件:行号`)
+> 与 **Matrix 事件实测**(`/sync` 回读,不是容器自述)。
+> **没有依据的地方明确标注「无法确认」,不做推测**。
+>
+> **参考实现**:`agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`),三项实测全通过。
+
+---
+
+## 零、先读这一节:三件最容易做错的事
+
+做错这三件,接入**看起来通了但体验很差**,而且很难排查。
+
+| # | 错误做法 | 后果 | 正确做法 |
+|---|---|---|---|
+| 1 | 用 `m.typing=false` 当「本轮结束」信号 | 实测早于正文 0.6 秒 → App 在正文到达前就收尾,用户看到「已完成」但没内容 | **用 ✅ reaction**(Hermes 内建,无需改造) |
+| 2 | 回复不挂 thread,也不做会话归属 | App 判成新会话,对话碎片化 | 沿用 App 给的 thread;详见 §三 |
+| 3 | 正文用 `~` 表示数值范围(`8~16℃`) | 成对的 `~` 被 markdown 解析成 `~~删除线~~`,整段被划掉 | 用全角 `~` 或「至」。App 侧已兜底,但容器侧也应规范 |
+
+---
+
+## 一、接入全貌
+
+```
+  ┌─────────────┐   ① provision(建 Matrix 身份 + DM 房间)
+  │  Fmode 平台  │◄──────────────────────────────────────┐
+  └─────────────┘                                       │
+         ▲                                              │
+         │ ② 状态上报(xlosi3m46q)                      │
+         │ ③ 文件登记(THXAOGKiPK)                      │
+         │                                              │
+  ┌──────┴──────┐    ④ Matrix 消息收发           ┌──────┴──────┐
+  │  容器        │◄──────────────────────────────►│  Fmode App  │
+  │ (Hermes)    │    DM 房间 / 群房间              │  (Angular)  │
+  └─────────────┘    ⑤ ✅/❌ reaction = 本轮结束   └─────────────┘
+```
+
+**两条链路缺一不可**:
+- **Matrix** 承载消息(实时、双向)
+- **云函数** 承载状态与文件台账(App 列表、成果栏依赖它)
+
+只接 Matrix 不接云函数 → App 里看不到这个容器,也没有成果归档。
+
+---
+
+## 二、身份与绑定
+
+### 2.1 平台侧前置(人工操作,容器做不了)
+
+| 步骤 | 说明 | 依据 |
+|---|---|---|
+| ① `FmodeAgent` 表有记录 | 按 `agentId` 建一条。**provision 不会自动建号** | 实测:对不存在的 id 调 provision 返回 `SDK登录失败: Object not found.` |
+| ② 调 provision 云函数 | `POST /api/functions`,`id: "RP5Ht55NeX"`,`params: { agentId }` | `core/api/fmode-api.ts:84` |
+| ③ 把结果写进容器 `.env` | 见 §2.2 | — |
+| ④ 重启 gateway | 见 §六 | — |
+
+> ⚠️ `RP5Ht55NeX` 的**入参/出参完整结构**:容器侧全盘检索后**没找到它的实现**(它在平台侧)。
+> App 侧只用到返回的 `agentMxId` / `accessToken` / `userMxId` / `homeserver`,
+> **其余字段无法确认**。需要完整契约请向平台方索取。
+
+### 2.2 容器 `.env` 必需变量
+
+`lj-test` 实测的条目(值为敏感信息,此处不列):
+
+```bash
+MATRIX_HOMESERVER          # 例:https://matrix.fmode.cn
+MATRIX_USER_ID             # 例:@zgdcmgnqcd:fmode.cn
+MATRIX_ACCESS_TOKEN        # 容器自己的 token
+MATRIX_DEVICE_ID
+MATRIX_ALLOWED_USERS       # 白名单:谁能给它派活
+MATRIX_IGNORE_USER_PATTERNS
+MATRIX_REQUIRE_MENTION     # 群聊里是否必须 @ 才响应
+MATRIX_AUTO_THREAD         # 建议 true
+MATRIX_HOME_ROOM
+MATRIX_HOME_ROOM_NAME
+MATRIX_REACTIONS           # 🔴 必须为 true(结束信号依赖它)
+FMODE_SESSION_TOKEN        # 调云函数用(主人 token)
+AGENT_ID                   # 容器自己的 agentId
+```
+
+**校验**:
+
+```bash
+node scripts/matrix-check.mjs <agentId>   # 本仓库只读体检,能看出缺哪一步
+```
+
+---
+
+## 三、消息收发
+
+### 3.1 收消息
+
+| 项 | 事实 | 依据 |
+|---|---|---|
+| 判定「是给我的」 | DM 都响应;群聊按 `MATRIX_REQUIRE_MENTION` + `MATRIX_ALLOWED_USERS` | 容器侧 `adapter.py` 策略段 |
+| 文件 | `content.url` 必须是 **`mxc://`**,用 bot 自己的登录态下载 | 实测:https 塞进 `url` 会被**静默丢弃** |
+| 大小 | App 侧限制 20MB(`core/upload-limits.ts`) | — |
+
+### 3.2 回消息的格式约束
+
+**必须遵守**(App 靠这些识别正文/过程/文件):
+
+| 约束 | 说明 |
+|---|---|
+| 过程行独占一行 | 以固定 emoji 开头(`💻` `🔍` `📖` `✍️` `🐍` `⚙️`) |
+| 工具行不与中文结论混排 | App 按行拆分,混排会把结论误判成过程 |
+| 结论整段发完 | 不要为了"流式"把一句话拆成多片 |
+| 过程分条 | 工具进度可以分条,让用户立刻看到进展 |
+
+### 3.3 thread 与会话归属
+
+| 结论 | 依据 |
+|---|---|
+| 容器**默认不挂 thread**(只发普通消息) | 容器自述 + App 侧 `chat.service.belongsToSession()` 的多重回退 |
+| App 有兜底:无 thread 的消息按 `sessionId` 标记 / 实时性 / 历史回填 分别认领 | `core/chat.service.ts` |
+| 但**挂 thread 更可靠**:thread 根 = 会话 id,归属零歧义 | — |
+
+**建议**:`MATRIX_AUTO_THREAD=true`,并沿用 App 给的 thread 根,不自己新开。
+
+### 3.4 `fmode.kind`(可选增强)
+
+容器可在消息 content 里声明 `"fmode.kind": "process" | "final"`,App 会直接采用、
+不再靠 emoji 猜。**当前 lj-test 做不到**:`/opt/hermes` 是 **root 属主 + 无写权限**
+(不是只读挂载,overlayfs 是 rw,但 `uid=10000(hermes)` 写不进去,实测 `PermissionError`)。
+
+→ **不是接入前提**,App 会自动回退到 emoji 启发式。
+
+---
+
+## 四、文件
+
+### 4.1 用户发来的文件
+
+App **双写**:Matrix 存 `mxc://`(给容器读)+ 云盘存 https(给 App 长期看)。
+容器只需读 `content.url` 的 `mxc://`。
+
+### 4.2 容器产出文件
+
+```
+① 容器产出文件到本地
+② 申请上传地址:云函数 AlP56LCKFm  → 返回 OBS 预签名 URL
+③ PUT 到预签名 URL
+④ 登记台账:云函数 THXAOGKiPK(action: registerFile,带 threadRoot)
+⑤ 在回复正文里给出链接
+```
+
+| 项 | 要求 |
+|---|---|
+| 链接块格式 | 正文末尾**空一行** → `**已上云(可直接下载)**` → 每条链接一行 `- https://...` |
+| 允许的域名 | `s3.fmode.cn` 或 `file-cloud.fmode.cn` |
+| 链接必须真实 | **手写占位链接会被 App 侧清洗掉**(只认真实上传后推送的) |
+
+> `THXAOGKiPK` 的完整 action 清单:App 侧用到 `registerFile` / `listBySession` / `listByAgent`。
+> 其余 action **无法确认**。
+
+---
+
+## 五、状态上报
+
+### 5.1 上报协议
+
+```
+POST https://server.fmode.cn/api/functions
+{ "id": "xlosi3m46q", "token": "<主人 sessionToken>",
+  "params": { "agentId": "...", "field": "<字段名>", "value": <值> } }
+```
+
+- **一次只报一个字段**
+- 字段白名单(白名单外直接 400):
+  `status currentTask currentAction progress name containerRef host heartbeatAt botName botAvatar gender ownerName ownerMobile balance channels extra sessionStatus usage usageSessions`
+- 对象/数组字段发**紧凑 JSON 字符串**
+- `heartbeatAt` 必须 `{"__type":"Date","iso":"..."}`
+
+### 5.2 上报时机
+
+| 时机 | 上报什么 | 依据 |
+|---|---|---|
+| turn 开始(`pre_llm_call`) | `status=running` + `currentTask` | `plugin-active-reporter` |
+| turn 结束(`post_llm_call`) | `status=idle` + `currentTask=""` | 同上 |
+| 心跳 | `heartbeatAt` + `balance`,间隔 3600s + 0~900s 随机 | 同上 |
+
+**App 侧离线判定阈值:80 分钟**(`core/agent-liveness.ts` `HEARTBEAT_STALE_SEC`)。
+心跳周期必须小于它。
+
+### 5.3 `extra.currentSessions`(会话级运行态)
+
+```json
+{ "currentSessions": [
+    { "sessionKey": "agent:main:matrix:dm:!roomId:threadId", "status": "running", "turnStartAt": 1699999999000 }
+]}
+```
+
+**App 靠它区分「这条会话在跑」还是「别的会话在跑」**。没有它,会话页只能显示容器全局状态
+→ 用户会看到「打开会话 A,右上角显示会话 B 的进度」。
+
+> ⚠️ **lj-test 当前的问题(实测)**:`currentSessions` **只在 turn 活动时写**,心跳不带它;
+> 且状态上报 cron(`*/2`)的 `enabled=False`,实际已停摆(最后一次成功 11-07 22:42)。
+> 新容器接入时**务必让心跳也带上它**,否则 App 会话列表状态会不全。
+
+---
+
+## 六、本轮结束信号(**接入必做**)
+
+### 6.1 结论:用 ✅ reaction,不要用 typing
+
+**实测证据**(`/sync` 回读,三场景):
+
+```
++0.8s   reaction 👀
++6.7s   typing=true
++14.8s  typing=false        ← 早于正文 0.6 秒 ✗
++15.3s  消息「3 乘以 7 等于 21。」
++15.4s  reaction ✅         ← 晚于正文 ✓
+```
+
+| 场景 | 正文时刻 | ✅ 时刻 | 判定 |
+|---|---|---|---|
+| 普通一问一答 | +21.7s | +21.7s | ✅ 晚 0.0s |
+| 多步长任务 | +36.0s | +36.0s | ✅ 晚 0.1s,中途无多余 ✅ |
+| 两会话并发 | +23.6s | +23.6s | ✅ 各自房间,不串台 |
+
+### 6.2 覆盖率(容器代码取证)
+
+| 情况 | 是否发 |
+|---|---|
+| 正常完成 | ✅ |
+| 报错 / 超时 | **❌**(不是不发) |
+| `CANCELLED` | 不发(仅用户 `/stop` `/new` `/reset` 或网关重启) |
+| 群聊多容器 | **各自发各自的**,打在同一个 event_id 上,按 sender 区分 |
+| 子任务 / detached fork | **不发**(源码注释明写 must stay silent,不会误收尾) |
+
+### 6.3 容器要做什么
+
+**什么都不用做**——Hermes 架构自带。只需确认:
+
+```bash
+MATRIX_REACTIONS=true    # 默认就是 true,别关
+```
+
+### 6.4 App 侧如何消费(供理解)
+
+| 优先级 | 信号 | 含义 |
+|---|---|---|
+| 高 | ✅/❌ reaction | 本轮**确实**结束了 |
+| 低 | `m.typing=false` | 只是「此刻没在打字」 |
+| 兜底 | 静默窗口 + 5 分钟超时 | 什么都没有时,按时间提示(**不改状态**) |
+
+按「消息 id 认领」而非时间戳比较,避免服务端/本机时钟差导致信号失效。
+群聊必须**按 sender 区分**,否则 A 容器收工会被当成 B 也收工。
+
+---
+
+## 七、容器能力边界(决定哪些改造可行)
+
+| 路径 | 权限 | 能做什么 |
+|---|---|---|
+| `/opt/hermes` | `root:root`,`hermes` 用户**无写权限**(不是只读挂载,overlayfs 是 rw,但 `PermissionError`) | 只能读 |
+| `/opt/data` | `hermes` 所有,可写 | 插件、配置、脚本、队列状态 |
+| `config.yaml` | 被 Hermes 保护,手改会被拦 | 用 `hermes plugins enable/disable` |
+
+**插件机制**:
+
+```python
+def register(ctx) -> None:
+    ctx.register_hook("pre_llm_call", _on_pre)
+    ctx.register_hook("post_llm_call", _on_post)
+```
+
+常用钩子:`pre_tool_call` `post_tool_call` `transform_llm_output` `pre_llm_call`
+`post_llm_call` `on_stream_start/delta/end` `on_interim_message` `pre_verify`
+`pre_api_request` `post_api_request` `on_session_start/end` `subagent_start/stop`
+
+**两个坑**:
+- `transform_llm_output` 必须返回 **`str`** 才能替换正文;shell 脚本钩子改不了正文
+- `pre_llm_call` 可返回 `{"context": "..."}` 注入上下文
+
+**改配置后生效**:
+
+```bash
+hermes plugins enable <name>            # 启停插件
+s6-svc -r /run/s6/services/main-hermes  # 重启网关(最稳)
+# 或 hermes gateway run --replace
+```
+
+---
+
+## 八、接入验收清单
+
+全部通过才算接通(**用回读验证,不采信容器自述**):
+
+### 基础
+- [ ] `node scripts/matrix-check.mjs <agentId>` 三项全绿
+- [ ] App 会话列表能看到它,状态「在线」
+
+### 消息
+- [ ] 发一条文字,能收到回复
+- [ ] 发一张图 + 一句「看看这个」,能描述图片内容
+- [ ] 回复里工具行与中文结论**不在同一行**
+
+### 文件
+- [ ] 发一个文件给它,容器能读取
+- [ ] 容器产出一个 .md,App 右栏「成果」出现卡片,**刷新后仍在**
+
+### 结束信号
+- [ ] 正文出现后 ✅ 跟着出现
+- [ ] 正文一出来,界面**立刻**从「输出中」变成「已完成」
+- [ ] 多步长任务**中途不提前**显示「已完成」
+
+### 状态
+- [ ] 工作台显示余额,与 Studio 充值页一致
+- [ ] 任务结束后 `status=idle`、`currentTask=""`
+- [ ] **两个会话并发时,各自状态互不干扰**
+
+### 建议测法
+
+```bash
+node scripts/verify-reaction-coverage.mjs   # ✅ 时序(三场景)
+node scripts/test-multi-session.mjs          # 多会话并发不串台
+```
+
+---
+
+## 九、已知缺口与兜底
+
+| 情况 | 容器行为 | App 兜底 |
+|---|---|---|
+| `CANCELLED` | 不发 reaction | 静默 + 5 分钟超时提示 |
+| 容器崩溃 | 不发 reaction | 同上 + 「容器无响应」提示 |
+| `MATRIX_REACTIONS=false` | 从不发 | 全部走时间兜底 |
+| 正文带 `~` 范围 | 照常发 | App 转义单个 `~`,不会误成删除线 |
+| 正文带 `---` | 照常发 | 渲染成分隔线(建议容器不用) |
+
+---
+
+## 十、参考实现对照
+
+`agent-node-lj-test` 的实测值:
+
+| 项 | 值 |
+|---|---|
+| agentId | `agent-node-lj-test` |
+| Matrix | `@zgdcmgnqcd:fmode.cn` |
+| 心跳 | 3600s + 0~900s 随机(实测上报已停摆,靠插件补) |
+| 结束信号 | ✅ reaction,晚于正文 0.0~0.1s |
+| 插件 | `plugin-active-reporter`(启用)、`plugin-deliverable-inject`(启用)、`plugin-turn-end`(**已禁用**,typing 方案作废) |
+
+---
+
+*本文档所有事实均来自 2026-11-11 的代码取证与 Matrix 事件实测。
+标「无法确认」的条目未经证实,接入前请向平台方核实。*

+ 117 - 0
docs/03-interface/容器接入规范提示词-20261111.md

@@ -295,6 +295,18 @@ API_SERVER_KEY
 - [ ] 心跳周期 ≤ 80 分钟
 - [ ] 上报 `status` / `currentTask` / `channels` / `extra.currentSessions`
 
+### 结束信号(**接入必做**,见 §七点五)
+- [ ] `MATRIX_REACTIONS=true`(没关掉)
+- [ ] 发一条消息后,Matrix 里能看到容器打的 `✅` reaction
+- [ ] `✅` 出现在**正文之后**(不是之前)
+- [ ] 多步工具任务里,`✅` **只在最后出现一次**
+- [ ] **不要**试图用 `m.typing=false` 当结束信号 —— 实测时序是反的,见 §七点五
+
+### 会话级运行态(`extra.currentSessions`)
+- [ ] `pre_llm_call` 时写入 `{sessionKey, status:"running", turnStartAt}`
+- [ ] `post_llm_call` 时改回 `status:"idle"`
+- [ ] **多个会话并发时要各自独立** —— App 靠它判断「这条会话在跑还是别人在跑」
+
 ### 结构化标记(可选 —— 取决于容器是否有写权限)
 - [ ] **先确认容器能写 `/opt/hermes`(或对应 adapter 目录)** —— 只读则本项无法做
 - [ ] Matrix adapter 的 `send()` 落 `fmode.kind: "process" | "final"`
@@ -350,9 +362,114 @@ fetch('https://server.fmode.cn/api/functions', {
 | 5 | 工具行与结论混排 | App 按行拆分时会把结论误判成过程 |
 | 6 | 回复不挂 thread | App 判成新会话,对话碎片化 |
 | 7 | 心跳间隔 >80 分钟 | App 判离线,用户看到灰色 |
+| 8 | **用 `/messages` 查 `m.typing`** | `m.typing` 是 **ephemeral 事件,永远不进 timeline**,`/messages` 读回来 0 条不代表没发。必须走 `/sync` 的 `RoomMember.typing` 验证 |
+| 9 | **未发本轮结束信号** | 整轮不发任何结束信号 → App 只能等静默窗口,20 秒的活显示到 50 多秒还「输出中」(用户实测) |
+| 10 | **拿 `m.typing=false` 当结束信号** | 实测早于正文 0.6 秒,会导致「还没回就说完了」。**改用 ✅ reaction**,见 §七点五 |
+| 11 | **正文用 `~` 表示范围** | `8~16℃`、`3~12℃` 成对出现会被 markdown 解析成 `~~删除线~~`,整段被划掉。**改用 `~`(全角)或 `至`**。App 侧已兜底,仍建议容器侧规范 |
+| 12 | **正文里单独一行 `---`** | 渲染成 `<hr>` 分隔线,把连续说明切碎。聊天正文不要用 markdown 分隔线 |
 
 ---
 
+## 七点五、本轮结束信号(2026-11-11 实测定稿,**接入必做**)
+
+App 侧必须知道「这一轮真的干完了」,否则只能靠静默窗口猜,就会出现
+**「正文早就输出完了,界面还挂着『输出中』几十秒」**,或者反过来
+**「先显示已完成,过很久正文才出来」**。
+
+### 7.5.1 用 ✅ reaction,不要用 typing
+
+**结论先行:结束信号 = 容器在 Matrix 里给用户消息打的 `✅` reaction。**
+
+#### 为什么不用 `m.typing`
+
+一开始的方案是「容器发 `m.typing=false` 表示结束」。**实测推翻了它**:
+
+```
++6.7s   typing=true
++14.8s  typing=false        ← 早于正文 0.6 秒
++15.3s  消息「3 乘以 7 等于 21。」
++15.4s  reaction ✅         ← 晚于正文 ✓
+```
+
+Hermes 架构里,`typing=false` 由 `_handle_message` 的 `finally` 块发出
+(`gateway/platforms/base.py`),**在消息真正发送之前**执行,时序天然是反的。
+而且关掉 `typing_indicator: false` 也砍不掉它——那条 `_stop_typing_quietly`
+不检查这个开关(实测验证)。
+
+自己写插件补一个 typing 也不行:会和 adapter 自己那条抢,总有一条抢在正文前。
+
+#### 为什么 ✅ reaction 是对的
+
+容器在 `on_processing_complete()`(`plugins/platforms/matrix/adapter.py:2331`)
+里,**消息发送之后**才把 👀 换成 ✅/❌。时序天然正确,且是 Hermes 内建行为,
+**不需要任何插件改造**。
+
+#### 实测覆盖率(2026-11-11,lj-test,三个场景)
+
+| 场景 | 正文时刻 | ✅ 时刻 | 结果 |
+|---|---|---|---|
+| 普通一问一答 | +21.7s | +21.7s | ✅ 晚 0.0s |
+| 多步长任务(多次工具) | +36.0s | +36.0s | ✅ 晚 0.1s,中途无多余 ✅ |
+| 两会话并发 | +23.6s | +23.6s | ✅ 各自房间,不串台 |
+
+**多步长任务里 ✅ 只在最后出现一次**,不会被中间步骤骗到提前收尾。
+
+### 7.5.2 容器侧要做什么
+
+**什么都不用做** —— Hermes 架构的容器自带这个行为。
+
+唯一要确认的是 `MATRIX_REACTIONS` 没被关掉(默认 `true`):
+
+```
+# /opt/data/.env 或 config
+MATRIX_REACTIONS=true
+```
+
+如果某个容器关了这个开关,它就不发 ✅/❌,App 会退回到静默窗口兜底
+(能跑,但体验差,会出现「已完成」残留或「输出中」残留)。
+
+### 7.5.3 App 侧的消费逻辑(供对接方理解,不用你实现)
+
+| 信号 | 优先级 | 含义 |
+|---|---|---|
+| `✅` / `❌` reaction | **最高** | 本轮**确实**结束了 |
+| `m.typing=false` | 低 | 只是「此刻没在打字」,中途空档也会有 |
+| 静默窗口 | 兜底 | 什么都没有时,按时间猜(90 秒) |
+
+App 用「消息 id 认领」而不是时间戳比较:收到 ✅ 的那一刻记下
+「本会话最后一条 agent 消息的 id」,回合判定时比对 id。
+这样**不跨时钟**(消息 `ts` 来自服务端、信号到了才记本地时刻),
+天然免疫服务端/本地时差。
+
+### 7.5.4 验收方法
+
+在 App 里随便发一条消息,观察:
+
+- [ ] 正文出现后,`✅` reaction 也跟着出现(消息左下角或 hover 可见)
+- [ ] 正文一出来,界面**立刻**从「输出中」变成「已完成」,不再干挂几十秒
+- [ ] 长任务(多步工具)中途**不会**提前显示「已完成」
+
+程序化验证(本仓库):
+
+```bash
+node scripts/verify-reaction-coverage.mjs   # 三个场景的 ✅ 时序
+node scripts/verify-typing-order.mjs        # 反向证据:typing 为什么不能用
+```
+
+### 7.5.5 已知缺口
+
+| 情况 | 行为 | App 兜底 |
+|---|---|---|
+| 容器 `CANCELLED` | 不发 reaction | 静默 90 秒后收尾 |
+| 容器崩溃/无响应 | 不发 reaction | 静默 90 秒后收尾 + 180 秒超时提示 |
+| 容器关了 `MATRIX_REACTIONS` | 从不发 reaction | 全部走静默兜底 |
+| 群聊 @ 多个容器 | 各自发自己的 ✅ | 按块(agent)分别判定 |
+
+**对未接入的容器**:没有 reaction 也能用,只是回到「靠时间猜」,体验差一些。
+
+---
+
+
 ## 八、验收标准
 
 新容器接入后,用以下 3 个测试确认:

+ 189 - 0
docs/03-interface/提示词-容器接入Fmode App-可发给容器.md

@@ -0,0 +1,189 @@
+# 提示词:把容器接入 Fmode App(可直接复制发给容器)
+
+> **用法**:把本文档全文发给要接入的容器,让它自我排查并改造。
+> **配套**:`docs/03-interface/容器接入完整指南-v2-20261111.md`(给人看的版本)
+>
+> 本文档中的每一条要求都有实测依据。容器自查时**请对照自己的真实代码**,
+> 做不到就如实说「做不到 + 原因」,**不要声称已完成** —— App 侧会回读 Matrix 事件核实。
+
+---
+
+## 你的任务
+
+你要接入 **Fmode App**(Angular 单页应用,线上 `https://ai.fmode.cn`)。
+接入分两部分:**Matrix 消息链路** + **云函数状态/文件链路**。
+
+请按下面的步骤**逐项自查**,把结果整理成表格回复我。
+**每一行都必须给出「当前状态 + 证据(文件:行号 或 实测输出)」**。
+
+---
+
+## 第一步:身份与配置自查
+
+逐项检查你的 `.env` / 配置,告诉我**每一项的当前值(敏感值只报是否存在,不要贴内容)**:
+
+```
+MATRIX_HOMESERVER          # 必需
+MATRIX_USER_ID             # 必需,形如 @xxx:fmode.cn
+MATRIX_ACCESS_TOKEN        # 必需
+MATRIX_DEVICE_ID
+MATRIX_ALLOWED_USERS       # 谁能给你派活
+MATRIX_REQUIRE_MENTION     # 群聊是否必须 @
+MATRIX_AUTO_THREAD         # 建议 true
+MATRIX_REACTIONS           # 🔴 必须 true(结束信号依赖)
+FMODE_SESSION_TOKEN        # 调云函数用
+AGENT_ID
+```
+
+**特别确认 `MATRIX_REACTIONS`**:如果它是 `false`,你的回复永远不会带 ✅,
+App 只能靠「等 5 分钟」来判断你干完了 —— 体验会很差。
+
+---
+
+## 第二步:结束信号自查(**最关键**)
+
+### 背景
+
+App 需要知道你「这一轮真的干完了」,否则只能猜。**猜错的两种后果**:
+
+- 猜早了 → 用户看到「已完成」,但真正的答案还没出来
+- 猜晚了 → 正文早就显示完了,界面还挂着「输出中」几十秒
+
+### 正确做法:用 ✅ reaction,不要用 `m.typing`
+
+**为什么不能用 typing**(实测数据):
+
+```
++6.7s   typing=true
++14.8s  typing=false        ← 早于正文 0.6 秒
++15.3s  消息「3 乘以 7 等于 21。」
++15.4s  reaction ✅         ← 晚于正文 ✓
+```
+
+Hermes 架构里 `typing=false` 在消息**发送之前**由 `finally` 块发出,时序天然是反的。
+
+**✅ reaction 为什么对**:`on_processing_complete()` 在消息**发送之后**才把 👀 换成 ✅/❌。
+
+### 请自查
+
+1. 你是 Hermes 架构吗?(`/opt/hermes` 存在吗?)
+2. 如果是:你的 `MATRIX_REACTIONS` 是 true 吗?
+3. 实测:你发一条回复后,会不会在 Matrix 里打出 ✅ reaction?
+4. 如果不会,原因是什么?
+
+**如果你已经装过 `plugin-turn-end` 之类的 typing 插件,请禁用掉** ——
+它和 adapter 自己那条会互相抢,反而让时序更乱。
+
+---
+
+## 第三步:消息格式自查
+
+| 要求 | 说明 |
+|---|---|
+| 过程行**独占一行** | 以 `💻` `🔍` `📖` `✍️` `🐍` `⚙️` 开头 |
+| 工具行**不与中文结论混排** | App 按行拆分,混排会把结论误判成过程 |
+| 结论**整段发完** | 不要为「流式」把一句话拆成多片 |
+| 回复**挂 thread** | 沿用 App 给的 thread 根,别自己新开 |
+
+自查并回答:
+1. 你的回复里,工具行和正文是否在同一行?
+2. 你发消息时挂 thread 吗?如果不挂,为什么?
+3. 你的正文里有用 `~` 表示数值范围吗(如 `8~16℃`)?
+   → **有的话请改用全角 `~` 或「至」**:成对的 `~` 会被 markdown 解析成 `~~删除线~~`,整段被划掉。
+
+---
+
+## 第四步:文件链路自查
+
+| 场景 | 要求 |
+|---|---|
+| 接收用户文件 | 从 `content.url` 读,**必须是 `mxc://`**(https 塞进 url 会被静默丢弃) |
+| 回传文件 | ① 上传 OBS(云函数 `AlP56LCKFm` 取预签名 URL)② 登记台账(云函数 `THXAOGKiPK`,`registerFile`)③ 正文给链接 |
+| 链接格式 | 正文末尾空一行 → `**已上云(可直接下载)**` → 每行一条 `- https://...` |
+| 域名 | `s3.fmode.cn` 或 `file-cloud.fmode.cn` |
+
+自查并回答:
+1. 用户发文件给你,你从哪里读?
+2. 你产出文件后,走的是哪条上传链路?调哪个云函数?
+3. 链接是真实上传后推送的,还是你手写的?(**手写占位会被 App 清洗掉**)
+
+---
+
+## 第五步:状态上报自查
+
+### 协议
+
+```
+POST https://server.fmode.cn/api/functions
+{ "id": "xlosi3m46q", "token": "<主人 sessionToken>",
+  "params": { "agentId": "...", "field": "<字段名>", "value": <值> } }
+```
+
+一次只报一个字段。字段白名单:
+`status currentTask currentAction progress name containerRef host heartbeatAt botName botAvatar gender ownerName ownerMobile balance channels extra sessionStatus usage usageSessions`
+
+### 🔴 重点:`extra.currentSessions`
+
+```json
+{ "currentSessions": [
+    { "sessionKey": "agent:main:matrix:dm:!roomId", "status": "running", "turnStartAt": 1699999999000 }
+]}
+```
+
+**App 靠它区分「这条会话在跑」还是「别的会话在跑」。**
+没有它,用户打开会话 A 会看到会话 B 的进度 —— 这是已经发生过的真实投诉。
+
+自查并回答:
+1. 你的 `pre_llm_call` / `post_llm_call` 有没有维护 `currentSessions`?
+2. **心跳上报时带不带 `currentSessions`?**(lj-test 的问题就是只在上报 turn 时带)
+3. 你的定时上报任务**还在跑吗**?(lj-test 的 cron 是 `enabled=false`,已停摆一个多月)
+
+---
+
+## 第六步:能力边界确认
+
+不同容器的权限不同,决定哪些改造可行:
+
+```bash
+ls -ld /opt/hermes /opt/data
+test -w /opt/hermes/config.yaml && echo "hermes 可写" || echo "hermes 不可写"
+```
+
+自查并回答:
+1. `/opt/hermes` 你能写吗?
+2. `/opt/data` 你能写吗?
+3. 你支持插件机制吗?(`register(ctx)` + `ctx.register_hook(name, fn)`)
+4. 改配置后怎么生效?重启命令是什么?
+
+---
+
+## 交付格式
+
+请按下面格式回复(**不要漏掉「证据」列**):
+
+| 检查项 | 当前状态 | 证据(文件:行号 / 实测输出) | 需要改什么 |
+|---|---|---|---|
+| MATRIX_REACTIONS | true | config.yaml:xx | 无需改 |
+| 结束信号 | 发 ✅ | 实测 +15.4s | 无需改 |
+| currentSessions | 未上报 | — | **需补** |
+| ... | ... | ... | ... |
+
+最后给一段**结论**:
+- 哪些项已满足
+- 哪些需要改、怎么改
+- 哪些**做不到**、原因是什么
+
+---
+
+## ⚠️ 三条纪律
+
+1. **不要声称「已完成」而不给证据** —— App 侧会回读 Matrix 事件核实。
+   之前有容器口头说改完了,实际回读发现改的是只读文件,一个字没生效。
+2. **做不到就说做不到** —— 有些容器 `/opt/hermes` 无写权限,那是环境限制,不是你的错。
+   如实说明比假装完成有用得多。
+3. **改完必须实测回读** —— 用 `/sync` 看真实事件,不要看日志或自己发的文本。
+
+---
+
+*本提示词基于 `agent-node-lj-test` 的逐行代码取证与 Matrix 事件实测编写(2026-11-11)。
+无法确认的地方已标注,不做推测。*

+ 194 - 0
scripts/test-multi-session.mjs

@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+/**
+ * 多会话并发测试(2026-11-11 用户要求)
+ *
+ * 目的:同一容器同时开两个会话并发提问,观察:
+ *   ① 回复是否会串台(A 会话收到 B 的回答)
+ *   ② 回复是否丢失(只回了一句「我先去查」就没下文)
+ *   ③ typing 结束信号是按会话发还是按容器发
+ *   ④ extra.currentSessions 能否区分两个会话的运行态
+ *
+ * 用法:node scripts/test-multi-session.mjs
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const F = 'https://server.fmode.cn/api/functions';
+const TARGET = 'agent-node-lj-test';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
+
+async function prov(id) {
+  const r = await fetch(F, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }),
+  });
+  const j = await r.json();
+  const d = j.result ?? j;
+  if (!d?.accessToken) throw new Error('provision 失败: ' + JSON.stringify(j).slice(0, 200));
+  return d;
+}
+
+/** 读云函数,看 currentSessions 有没有区分两个会话 */
+async function readAgentStatus() {
+  const r = await fetch(F, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: 'xlosi3m46q', token: tok, params: { agentId: TARGET } }),
+  });
+  const j = await r.json();
+  const d = j.result ?? j;
+  const ex = d?.extra;
+  const parsed = typeof ex === 'string' ? safeJson(ex) : ex;
+  return {
+    status: d?.status,
+    currentTask: d?.currentTask,
+    currentSessions: parsed?.currentSessions,
+  };
+}
+
+function safeJson(s) {
+  try {
+    return JSON.parse(s);
+  } catch {
+    return null;
+  }
+}
+
+const p = await prov(TARGET);
+const a = await prov(TARGET);
+const mx = await import('matrix-js-sdk');
+const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId });
+await c.startClient({ initialSyncLimit: 1 });
+await sleep(3000);
+
+const hs = p.homeserver.replace(/\/$/, '');
+const auth = { Authorization: `Bearer ${p.accessToken}` };
+
+async function makeRoom(tag) {
+  const room = (
+    await c.createRoom({
+      invite: [a.agentMxId],
+      name: `${tag}-${Date.now()}`,
+      preset: mx.Preset.TrustedPrivateChat,
+    })
+  ).room_id;
+  for (let i = 0; i < 30; i++) {
+    await sleep(2000);
+    const r = await fetch(
+      `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,
+      { headers: auth },
+    ).catch(() => null);
+    const j = r ? await r.json().catch(() => ({})) : {};
+    if ((j.joined ?? {})[a.agentMxId]) return room;
+  }
+  throw new Error(`房间 ${tag} 容器未进房`);
+}
+
+/** 事件收集器:按房间记录 agent 消息 / typing / 自身消息 */
+const log = { A: [], B: [], typing: [] };
+const T0 = Date.now();
+const rel = (t) => `+${((t - T0) / 1000).toFixed(1)}s`;
+let roomA, roomB;
+
+c.on('Room.timeline', (ev, rm) => {
+  const rid = rm?.roomId;
+  if (rid !== roomA && rid !== roomB) return;
+  const tag = rid === roomA ? 'A' : 'B';
+  if (ev.getType() !== 'm.room.message') return;
+  const mine = ev.getSender() === p.userMxId;
+  if (mine) return;
+  const body = String(ev.getContent()?.body ?? '');
+  log[tag].push({ at: Date.now(), body });
+  console.log(`[${rel(Date.now())}] 房间${tag} agent: ${JSON.stringify(body.slice(0, 70))}`);
+});
+
+c.on('RoomMember.typing', (ev, m) => {
+  if (m?.roomId !== roomA && m?.roomId !== roomB) return;
+  const tag = m.roomId === roomA ? 'A' : 'B';
+  log.typing.push({ at: Date.now(), tag, typing: m.typing });
+  console.log(`[${rel(Date.now())}] 🔵 房间${tag} typing=${m.typing}`);
+});
+
+console.log('\n=== 建两个会话 ===');
+roomA = await makeRoom('multiA');
+roomB = await makeRoom('multiB');
+console.log('房间 A =', roomA);
+console.log('房间 B =', roomB);
+
+console.log('\n=== 并发发送(间隔 0.5 秒)===');
+await c.sendEvent(roomA, 'm.room.message', {
+  msgtype: 'm.text',
+  body: '请详细介绍一下杭州西湖的历史,分 5 点说,每点写 2 句话。',
+  'm.mentions': { user_ids: [a.agentMxId] },
+});
+await sleep(500);
+await c.sendEvent(roomB, 'm.room.message', {
+  msgtype: 'm.text',
+  body: '请列出中国的 5 个一线城市,每个城市说一句特色。',
+  'm.mentions': { user_ids: [a.agentMxId] },
+});
+console.log('两条已发送');
+
+/* 期间每 15 秒记录一次容器上报的 currentSessions */
+console.log('\n=== 周期观察容器上报 ===');
+for (let i = 0; i < 10; i++) {
+  await sleep(15000);
+  const st = await readAgentStatus().catch(() => null);
+  if (st) {
+    console.log(
+      `[${rel(Date.now())}] 容器上报 status=${st.status} currentTask=${JSON.stringify((st.currentTask || '').slice(0, 40))} sessions=${JSON.stringify(st.currentSessions)}`,
+    );
+  }
+  // 两个房间都超过 60 秒没动静就提前结束
+  const lastA = log.A.at(-1)?.at ?? T0;
+  const lastB = log.B.at(-1)?.at ?? T0;
+  if (Date.now() - lastA > 60000 && Date.now() - lastB > 60000 && log.A.length && log.B.length) {
+    console.log('两边都静默超过 60 秒,提前结束观察');
+    break;
+  }
+}
+
+console.log('\n================ 结果 ================');
+for (const tag of ['A', 'B']) {
+  console.log(`\n【房间 ${tag}】agent 消息 ${log[tag].length} 条`);
+  log[tag].forEach((m, i) => console.log(`  ${i + 1}. ${JSON.stringify(m.body.slice(0, 80))}`));
+}
+console.log(`\n【typing 事件】共 ${log.typing.length} 条`);
+const byTag = { A: log.typing.filter((t) => t.tag === 'A'), B: log.typing.filter((t) => t.tag === 'B') };
+for (const tag of ['A', 'B']) {
+  const arr = byTag[tag];
+  console.log(`  房间${tag}: ${arr.map((t) => `${rel(t.at)}=${t.typing}`).join(' | ') || '无'}`);
+}
+
+/* 串台检测:B 的问题里有「一线城市」,A 的回答不该出现它 */
+const crossA = log.A.filter((m) => m.body.includes('一线城市'));
+const crossB = log.B.filter((m) => m.body.includes('西湖'));
+console.log('\n【串台检测】');
+console.log('  A 里出现 B 的内容:', crossA.length ? `❌ ${crossA.length} 条` : '✅ 无');
+console.log('  B 里出现 A 的内容:', crossB.length ? `❌ ${crossB.length} 条` : '✅ 无');
+
+console.log('\n【回复完整性】');
+for (const tag of ['A', 'B']) {
+  const err = await probeRoom(tag === 'A' ? roomA : roomB);
+  console.log(`  房间${tag} 原始事件数: ${err}`);
+}
+
+/** 直接读 timeline,确认有没有 App 没收到的事件 */
+async function probeRoom(roomId) {
+  const r = await fetch(
+    `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=100`,
+    { headers: auth },
+  );
+  const j = await r.json();
+  const bodies = (j.chunk ?? [])
+    .filter((e) => e.type === 'm.room.message' && e.sender === a.agentMxId)
+    .reverse();
+  console.log(`  ─ 房间 ${roomId.slice(-8)} timeline 里 agent 消息 ${bodies.length} 条:`);
+  bodies.forEach((e) => console.log(`     ${JSON.stringify(String(e.content?.body ?? '').slice(0, 70))}`));
+  return bodies.length;
+}
+
+process.exit(0);

+ 150 - 0
scripts/verify-reaction-coverage.mjs

@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+/**
+ * 验证 ✅ reaction 作为结束信号的覆盖率与边界(2026-11-11)
+ *
+ * 关心三件事:
+ *   ① 普通回合:✅ 是否稳定出现、是否晚于正文
+ *   ② 多步/长工具回合:✅ 是否仍在**最后**才出现(不会被中间步骤骗)
+ *   ③ 并发两个会话:各自的 ✅ 是否只出现在各自房间
+ *
+ * 用法:node scripts/verify-reaction-coverage.mjs
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const F = 'https://server.fmode.cn/api/functions';
+const TARGET = 'agent-node-lj-test';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
+
+async function prov(id) {
+  const r = await fetch(F, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }),
+  });
+  const j = await r.json();
+  const d = j.result ?? j;
+  if (!d?.accessToken) throw new Error('provision 失败');
+  return d;
+}
+
+const p = await prov(TARGET);
+const a = await prov(TARGET);
+const mx = await import('matrix-js-sdk');
+const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId });
+await c.startClient({ initialSyncLimit: 1 });
+await sleep(3000);
+
+const hs = p.homeserver.replace(/\/$/, '');
+const auth = { Authorization: `Bearer ${p.accessToken}` };
+
+async function makeRoom(tag) {
+  const room = (
+    await c.createRoom({
+      invite: [a.agentMxId],
+      name: `${tag}-${Date.now()}`,
+      preset: mx.Preset.TrustedPrivateChat,
+    })
+  ).room_id;
+  for (let i = 0; i < 30; i++) {
+    await sleep(2000);
+    const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, {
+      headers: auth,
+    }).catch(() => null);
+    const j = r ? await r.json().catch(() => ({})) : {};
+    if ((j.joined ?? {})[a.agentMxId]) return room;
+  }
+  throw new Error('容器未进房');
+}
+
+const log = new Map(); // roomId -> [{t, kind, value}]
+const T0 = Date.now();
+const rel = (t) => ((t - T0) / 1000).toFixed(1);
+
+c.on('Room.timeline', (ev, rm) => {
+  const rid = rm?.roomId;
+  if (!log.has(rid)) return;
+  if (ev.getSender() !== a.agentMxId) return;
+  const type = ev.getType();
+  const arr = log.get(rid);
+  if (type === 'm.reaction') {
+    arr.push({ t: Date.now(), kind: 'reaction', value: ev.getContent()?.['m.relates_to']?.key ?? '' });
+  } else if (type === 'm.room.message') {
+    arr.push({ t: Date.now(), kind: 'msg', value: String(ev.getContent()?.body ?? '').slice(0, 35) });
+  }
+});
+
+console.log('建 3 个房间:普通 / 多步长任务 / 并发B\n');
+const rNormal = await makeRoom('rc-normal');
+const rMulti = await makeRoom('rc-multi');
+const rB = await makeRoom('rc-b');
+log.set(rNormal, []);
+log.set(rMulti, []);
+log.set(rB, []);
+
+const send = (rid, body) =>
+  c.sendEvent(rid, 'm.room.message', {
+    msgtype: 'm.text',
+    body,
+    'm.mentions': { user_ids: [a.agentMxId] },
+  });
+
+// ① 普通一问一答
+await send(rNormal, '用一句话回答:一年有多少个月?不要调用工具。');
+await sleep(1000);
+// ② 多步长任务(要求调用工具多次)
+await send(rMulti, '帮我依次算:12*12、13*13、14*14、15*15、16*16,每算一个都要用工具实际计算,最后汇总。');
+await sleep(1000);
+// ③ 并发第二个会话
+await send(rB, '用一句话回答:中国的首都是哪里?不要调用工具。');
+
+console.log('三条已发送,监听 180 秒…');
+await sleep(180_000);
+
+const names = [
+  ['普通一问一答', rNormal],
+  ['多步长任务', rMulti],
+  ['并发B', rB],
+];
+
+let allOk = true;
+for (const [name, rid] of names) {
+  const arr = log.get(rid);
+  console.log(`\n########## ${name} ##########`);
+  arr.forEach((e) => {
+    const tag = e.kind === 'reaction' ? `reaction ${e.value}` : `消息「${e.value}」`;
+    console.log(`  +${rel(e.t)}s  ${tag}`);
+  });
+  const msgs = arr.filter((e) => e.kind === 'msg');
+  const ok = arr.filter((e) => e.kind === 'reaction' && e.value === '✅');
+  const bad = arr.filter((e) => e.kind === 'reaction' && e.value === '❌');
+  const eye = arr.filter((e) => e.kind === 'reaction' && e.value === '👀');
+  console.log(
+    `  → 消息 ${msgs.length} 条 / 👀 ${eye.length} / ✅ ${ok.length} / ❌ ${bad.length}`,
+  );
+
+  if (ok.length === 0) {
+    console.log('  ⚠️ 没有 ✅ —— 需要兜底');
+    allOk = false;
+  } else {
+    const lastMsg = msgs.at(-1);
+    const lastOk = ok.at(-1);
+    if (lastMsg && lastOk) {
+      const d = (lastOk.t - lastMsg.t) / 1000;
+      console.log(
+        d >= 0
+          ? `  ✅ ✅ 晚于末条消息 ${d.toFixed(1)}s`
+          : `  ❌ ✅ 早于末条消息 ${(-d).toFixed(1)}s —— 会导致提前收尾`,
+      );
+      if (d < 0) allOk = false;
+    }
+    // 多步回合:中间是否出现多个 ✅(若多个,取最后一个才是真结束)
+    if (ok.length > 1) console.log(`  ℹ️ 出现 ${ok.length} 个 ✅ —— App 只认最后一个,需确认不会提前收尾`);
+  }
+}
+
+console.log(`\n===== 总结 =====`);
+console.log(allOk ? '✅ ✅ reaction 可作为可靠结束信号' : '❌ 存在覆盖缺口,App 侧必须保留静默兜底');
+process.exit(0);

+ 120 - 0
scripts/verify-typing-order.mjs

@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+/**
+ * 验证容器 typing 信号时序(2026-11-11)
+ *
+ * 要回答两个问题:
+ *   ① adapter 自带的 typing 循环是否已关闭(配置 typing_indicator: false)
+ *   ② typing=false 是否**晚于**最后一条 agent 消息
+ *
+ * 🔴 必须用 /sync 监听:`m.typing` 是 ephemeral 事件,**不写进 timeline**,
+ *    用 /messages 读永远读不到(2026-11-11 踩过这个坑,误判过一次)。
+ *
+ * 用法:node scripts/verify-typing-order.mjs
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const F = 'https://server.fmode.cn/api/functions';
+const TARGET = 'agent-node-lj-test';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
+
+async function prov(id) {
+  const r = await fetch(F, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }),
+  });
+  const j = await r.json();
+  const d = j.result ?? j;
+  if (!d?.accessToken) throw new Error('provision 失败');
+  return d;
+}
+
+const p = await prov(TARGET);
+const a = await prov(TARGET);
+const mx = await import('matrix-js-sdk');
+const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId });
+await c.startClient({ initialSyncLimit: 1 });
+await sleep(3000);
+
+const hs = p.homeserver.replace(/\/$/, '');
+const auth = { Authorization: `Bearer ${p.accessToken}` };
+const room = (
+  await c.createRoom({
+    invite: [a.agentMxId],
+    name: `typing-order-${Date.now()}`,
+    preset: mx.Preset.TrustedPrivateChat,
+  })
+).room_id;
+for (let i = 0; i < 30; i++) {
+  await sleep(2000);
+  const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, {
+    headers: auth,
+  }).catch(() => null);
+  const j = r ? await r.json().catch(() => ({})) : {};
+  if ((j.joined ?? {})[a.agentMxId]) break;
+}
+
+const events = [];
+const T0 = Date.now();
+const rel = (t) => (t - T0) / 1000;
+
+c.on('RoomMember.typing', (ev, m) => {
+  if (m?.roomId !== room) return;
+  events.push({ t: Date.now(), kind: 'typing', value: m.typing });
+});
+
+c.on('Room.timeline', (ev, rm) => {
+  if (rm?.roomId !== room) return;
+  const type = ev.getType();
+  if (type === 'm.reaction' && ev.getSender() === a.agentMxId) {
+    const key = ev.getContent()?.['m.relates_to']?.key ?? '';
+    events.push({ t: Date.now(), kind: 'reaction', value: key });
+  }
+  if (type === 'm.room.message' && ev.getSender() === a.agentMxId) {
+    events.push({ t: Date.now(), kind: 'msg', value: String(ev.getContent()?.body ?? '').slice(0, 40) });
+  }
+});
+
+await c.sendEvent(room, 'm.room.message', {
+  msgtype: 'm.text',
+  body: '用一句话回答:3 乘以 7 等于多少?不要调用工具。',
+  'm.mentions': { user_ids: [a.agentMxId] },
+});
+console.log('已发送,监听 100 秒…\n');
+
+await sleep(100_000);
+
+console.log('=== 事件时序(数字 = 距发送的秒数)===');
+for (const e of events) {
+  const tag = e.kind === 'typing' ? `typing=${e.value}` : e.kind === 'reaction' ? `reaction ${e.value}` : `消息「${e.value}」`;
+  console.log(`  +${rel(e.t).toFixed(1)}s  ${tag}`);
+}
+
+const msgs = events.filter((e) => e.kind === 'msg');
+const lastMsg = msgs.at(-1);
+const typingFalse = events.filter((e) => e.kind === 'typing' && e.value === false);
+const typingTrue = events.filter((e) => e.kind === 'typing' && e.value === true);
+
+console.log('\n=== 判定 ===');
+console.log(`typing=true  ${typingTrue.length} 次`);
+console.log(`typing=false ${typingFalse.length} 次`);
+console.log(`agent 消息   ${msgs.length} 条`);
+
+if (lastMsg && typingFalse.length) {
+  const lastFalse = typingFalse.at(-1);
+  const diff = rel(lastFalse.t) - rel(lastMsg.t);
+  console.log(`\n最后一条消息在 +${rel(lastMsg.t).toFixed(1)}s,最后一个 typing=false 在 +${rel(lastFalse.t).toFixed(1)}s`);
+  console.log(diff >= 0 ? `✅ false 晚于末条消息 ${diff.toFixed(1)}s(正确)` : `❌ false 早于末条消息 ${(-diff).toFixed(1)}s(仍然抢跑)`);
+} else if (msgs.length && !typingFalse.length) {
+  console.log('\n⚠️ 没有任何 typing=false —— 插件/adapter 都没发结束信号');
+} else {
+  console.log('\n⚠️ 数据不足,无法判定');
+}
+
+const rej = events.filter((e) => e.kind === 'reaction');
+console.log(`\n另:✅/❌ reaction ${rej.length} 次 ${rej.length ? '(这本身也是可靠的结束信号)' : ''}`);
+
+process.exit(0);

+ 58 - 3
src/app/components/domain/session-tree/session-tree.ts

@@ -23,7 +23,7 @@ import { ThemeService } from '../../../ui/theme';
 import { UnreadService } from '../../../core/unread.service';
 import { ChatService } from '../../../core/chat.service';
 import { agentStatusTone } from '../../../core/api/models';
-import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
+import { AgentStateView, resolveAgentState, sessionRunning } from '../../../core/agent-state';
 
 /** 工作区(用户手动新建的文件夹) */
 /** 一次「选择容器并新建对话」的请求(由页面弹模态框处理) */
@@ -702,9 +702,10 @@ export interface NewSessionRequest {
             @if (s.kind !== 'group') {
               <i
                 class="sess-dot"
+                [class.spinning]="sessionRunningOf(s)"
                 [attr.data-tone]="agentStateOf(s).tone"
-                [attr.title]="agentStateOf(s).text"
-                [attr.aria-label]="agentStateOf(s).text"
+                [attr.title]="sessionRunningOf(s) ? '这条会话正在执行' : agentStateOf(s).text"
+                [attr.aria-label]="sessionRunningOf(s) ? '这条会话正在执行' : agentStateOf(s).text"
                 aria-hidden="true"
               ></i>
             }
@@ -1429,6 +1430,42 @@ export interface NewSessionRequest {
       .sess-dot[data-tone='info'] {
         background: var(--primary);
       }
+      /*
+       * 会话正在执行 → 由静态圆点变成旋转环(2026-11-11 用户要求)。
+       *
+       * 用户原话:「左侧应该再增加一个加载的动画,就是图标」。
+       * 只做视觉增强:外圈旋转、颜色沿用该会话容器的 tone,
+       * 这样「谁的会话在跑」和「容器是否健康」两个信息都能同时读到。
+       *
+       * 只动画 transform(合成层),并响应 prefers-reduced-motion。
+       */
+      .sess-dot.spinning {
+        width: 9px;
+        height: 9px;
+        background: transparent !important;
+        border: 1.5px solid var(--edge-strong);
+        border-top-color: var(--success);
+        animation: sess-spin 0.75s linear infinite;
+      }
+      .sess-dot.spinning[data-tone='warning'] {
+        border-top-color: var(--warning);
+      }
+      .sess-dot.spinning[data-tone='danger'] {
+        border-top-color: var(--danger);
+      }
+      .sess-dot.spinning[data-tone='info'] {
+        border-top-color: var(--primary);
+      }
+      @keyframes sess-spin {
+        to {
+          transform: rotate(360deg);
+        }
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .sess-dot.spinning {
+          animation: none;
+        }
+      }
       .sess-title-wrap {
         flex: 1;
         min-width: 0;
@@ -2452,6 +2489,24 @@ export class SessionTreeComponent implements OnDestroy {
     return resolveAgentState({ agent: this.liveSvc.agentById(s.agentId) });
   }
 
+  /**
+   * **这一条会话**当前是否在跑(2026-11-11 用户要求「左侧列表加加载图标」)。
+   *
+   * 与 `agentStateOf` 的区别:
+   *   · `agentStateOf` 回答「这个**容器**是否可用/在忙」—— 整列同色
+   *   · 本方法回答「这个**会话**是否在跑」—— 只有真正在干活的那一条才转圈
+   *
+   * 数据来源是容器上报的 `extra.currentSessions`(容器侧 `plugin-active-reporter`
+   * 按 session_key 维护)。容器没上报会话级状态时返回 false,
+   * 回落到原来的静态圆点,不会凭空转圈。
+   */
+  protected sessionRunningOf(s: ChatSession): boolean {
+    const agent = this.liveSvc.agentById(s.agentId);
+    if (!agent) return false;
+    // currentSessions 的 sessionKey 形如 agent:main:matrix:dm:<roomId>[:<threadId>]
+    return sessionRunning(agent.currentSessions, s.id) === true;
+  }
+
   protected unreadOf(agentId: string): number {
     return this.unreadSvc.countOf(agentId);
   }

+ 54 - 0
src/app/components/preview/md-render/normalize-md.spec.ts

@@ -113,4 +113,58 @@ describe('normalizeMarkdown', () => {
     const src = '这句话里有空格 但不是在说标题。\n下一句继续。';
     expect(normalizeMarkdown(src)).toBe(src);
   });
+
+  /*
+   * 🔴 单波浪号被误判成删除线(2026-11-11 用户截图实证)。
+   *
+   * 现象:AI 回复里「**南昌**:…全天 8~16℃ … **北京**:…全天 3~12℃」整段被划了横线,
+   * 用户反复问「为什么会有横线」。起初误判成分隔线 `<hr>`,实际是删除线 `<del>`。
+   *
+   * 根因:`8~16℃` / `3~12℃` 里的两个单 `~` 在整段中**成对出现**,
+   * GFM 把中间内容解析成 `~~删除线~~`。实测 marked@16:
+   *   `温度 8~16℃ 和 3~12℃ 对比` → `温度 8<del>16℃ 和 3</del>12℃ 对比`
+   *
+   * 修法:单个 `~` → HTML 实体 `&#126;`(渲染出来仍是 `~`,但不参与语法解析)。
+   * 用反斜杠 `\~` 实测无效 —— `~` 不在 CommonMark 可转义字符表里。
+   */
+  describe('单波浪号防删除线', () => {
+    it('🔴 温度区间的 ~ 被转成实体,不再触发删除线', () => {
+      const out = normalizeMarkdown('全天 8~16℃,日出 06:36');
+      expect(out).withContext('单个 ~ 必须转实体').toContain('&#126;');
+      expect(out).toContain('8&#126;16℃');
+    });
+
+    it('🔴 一行里出现多个 ~(成对风险最高)也全部转义', () => {
+      const out = normalizeMarkdown('温度 8~16℃ 和 3~12℃ 对比');
+      expect((out.match(/&#126;/g) ?? []).length).toBe(2);
+      expect(out).not.toMatch(/[^&]#?126;~|~[0-9]/);
+    });
+
+    it('🔴 真正的删除线 ~~xxx~~ 必须保留(容器用它标注作废方案)', () => {
+      const out = normalizeMarkdown('这是 ~~旧方案~~ 作废');
+      expect(out).withContext('双波浪号不动').toContain('~~旧方案~~');
+      expect(out).not.toContain('&#126;&#126;');
+    });
+
+    it('🔴 混合:删除线保留、温度区间转义', () => {
+      const out = normalizeMarkdown('这是 ~~旧方案~~ 和 8~16℃ 混排');
+      expect(out).toContain('~~旧方案~~');
+      expect(out).toContain('8&#126;16℃');
+    });
+
+    it('不误伤:没有 ~ 的行原样不动', () => {
+      const src = '南昌全天 8-16℃,北京 -10℃。';
+      expect(normalizeMarkdown(src)).toBe(src);
+    });
+
+    it('🔴 代码块里的 ~ 必须原样保留(路径/正则里的波浪号有语义)', () => {
+      const src = '```\ncd ~/.ssh/config\n```';
+      expect(normalizeMarkdown(src)).toContain('~/.ssh/config');
+    });
+
+    it('幂等:重复执行不重复转义', () => {
+      const once = normalizeMarkdown('全天 8~16℃');
+      expect(normalizeMarkdown(once)).toBe(once);
+    });
+  });
 });

+ 39 - 2
src/app/components/preview/md-render/normalize-md.ts

@@ -194,6 +194,43 @@ export function normalizeMarkdown(text: string): string {
       continue;
     }
 
+    /*
+     * ⓪-1 中文正文里的单个 `~` → HTML 实体,防止被当成删除线(2026-11-11 用户实测)。
+     *
+     * ## 现象(截图实证)
+     *
+     * AI 回复里「**南昌**:…全天 8~16℃ … **北京**:…全天 3~12℃」整段被划了横线,
+     * 看起来像正文被删除 —— 用户反复反馈「为什么会有横线」。
+     *
+     * ## 根因
+     *
+     * 温度区间习惯写成 `8~16℃` / `3~12℃`。这些单波浪号在整段里**成对出现**,
+     * GFM 把两个 `~` 之间的所有内容解析成 `~~删除线~~`:
+     *
+     * ```
+     *   全天 8~16℃ … 3~12℃
+     *         ↑__________↑   → <del>16℃ … 3</del>
+     * ```
+     *
+     * 实测 marked@16:`温度 8~16℃ 和 3~12℃ 对比` → `<del>16℃ 和 3</del>`。
+     *
+     * ## 为什么用 HTML 实体而不是反斜杠转义
+     *
+     * `\~` 实测无效 —— `~` 不在 CommonMark 的可转义字符表里,反斜杠会被吃掉,
+     * 波浪号照样参与解析。`&#126;` 是 HTML 实体,渲染出来就是 `~`,且
+     * **不参与 markdown 语法解析**,两头都满足。
+     *
+     * ## 只处理单个 `~`,保留 `~~`
+     *
+     * 真正的删除线 `~~xxx~~` 必须保留(容器会用它标注作废方案),
+     * 所以用负向断言排除「紧邻另一个 ~」的情况。
+     */
+    /*
+     * 🔴 只做行内替换,**不 continue**:这一行后面还要参与标题断行判定,
+     *    跳过去会让「带 ~ 的标题行」失去分段(用户实测过同类漏判)。
+     */
+    const line0 = line.includes('~') ? raw.replace(/(?<!~)~(?!~)/g, '&#126;') : raw;
+
     /*
      * ⓪ 行尾是标题 → 先把它拆成两行(2026-11-09)。
      *
@@ -204,7 +241,7 @@ export function normalizeMarkdown(text: string): string {
      * CommonMark 会把它们算成同一段。这里先把行尾标题切到独立一行,
      * 再走下面的常规判定,最后自然得到「正文 \n\n 标题 \n\n 正文」。
      */
-    const headBefore = raw.trim() ? splitTrailingHeading(raw.trim()) : null;
+    const headBefore = line0.trim() ? splitTrailingHeading(line0.trim()) : null;
     if (headBefore) {
       out.push(headBefore.head);
       out.push('');
@@ -233,7 +270,7 @@ export function normalizeMarkdown(text: string): string {
       out.push('');
     }
 
-    out.push(raw);
+    out.push(line0);
 
     /*
      * 只在「当前行有内容、下一行也有内容」时才考虑补空行。

+ 173 - 10
src/app/core/assistant-turn.spec.ts

@@ -402,6 +402,72 @@ describe('turnsFromDm(单聊)', () => {
       expect(t.blocks[0].seconds).withContext('首末 agent 消息差').toBe(7);
     });
 
+    /*
+     * 🔴 容器结束信号(2026-11-11 用户实测:正文 16.8 秒发完,
+     *    界面挂到 50 多秒仍是「输出中」)。
+     *
+     * 根因:容器整轮不发任何结束信号,App 只能等静默窗口
+     * (无步骤 25 秒 / 有步骤 60 秒)。容器侧插件改为在 `post_llm_call`
+     * 发 `m.typing=false` 后,App 收到即收尾。
+     */
+    describe('容器结束信号 turnEndedAfter(按消息 id 认领)', () => {
+      it('🔴 信号绑定到本块末条消息 → 立刻收尾,不再等 60 秒宽限', () => {
+        const [t] = turnsFromDm(
+          // 有步骤(空 body 但有过程)时宽限是 STALL_MS=60s,这里正是要绕开它
+          [dmTurn([msg('a1', 'agent', '💻 bash', 16_000)], { closed: false })],
+          T0 + 20_000,
+          false,
+          25_000,
+          false,
+          'a1', // 信号认领的就是本块末条消息
+        );
+        const b = t.blocks[0];
+        expect(b.turnEnded).toBe(true);
+        expect(b.live).withContext('有结束信号就不该继续输出中').toBe(false);
+      });
+
+      it('无信号(未改造的老容器)→ 仍走静默窗口兜底', () => {
+        const [t] = turnsFromDm(
+          [dmTurn([msg('a1', 'agent', '💻 bash', 16_000)], { closed: false })],
+          T0 + 20_000,
+          false,
+          25_000,
+          false,
+          null, // 无信号
+        );
+        expect(t.blocks[0].turnEnded).toBe(false);
+        expect(t.blocks[0].live).withContext('没信号时行为不变,继续输出中').toBe(true);
+      });
+
+      it('信号绑到的是更早的消息(上一轮残留)→ 不认,避免误切断', () => {
+        const [t] = turnsFromDm(
+          [dmTurn([msg('a1', 'agent', 'a', 8000), msg('a2', 'agent', 'b', 30_000)], { closed: false })],
+          T0 + 32_000,
+          false,
+          25_000,
+          false,
+          'a1', // 本块末条是 a2,信号只说 a1 结束
+        );
+        expect(t.blocks[0].turnEnded).toBe(false);
+        expect(t.blocks[0].live).toBe(true);
+      });
+
+      it('有信号时读数定格在末条消息,与容器真实耗时一致', () => {
+        const [t] = turnsFromDm(
+          [dmTurn([msg('a1', 'agent', 'x', 4000), msg('a2', 'agent', 'y', 16_800)])],
+          T0 + 25_000,
+          false,
+          25_000,
+          false,
+          'a2',
+        );
+        const b = t.blocks[0];
+        expect(b.live).toBe(false);
+        // 16.8 秒干完 → 读数就是 16 秒,不会因为等了 25 秒而显示 25
+        expect(elapsedSeconds(b, T0 + 25_000)).toBe(16);
+      });
+    });
+
     it('无锚点(容器主动发言)→ 0,不显示', () => {
       const [t] = turnsFromDm(
         [dmTurn([msg('a1', 'agent', 'x', 1000)], { userMessage: undefined, startedAt: 0, userAskTimes: [] })],
@@ -609,8 +675,20 @@ describe('phaseOf(交付口径)', () => {
     const [running] = turnsFromDm([turnOf()], T0 + 30_000, false);
     expect(running.phase).withContext('跑工具的正常空档仍算输出中').toBe('replying');
 
-    const [stuck] = turnsFromDm([turnOf()], T0 + 90_000, false);
-    expect(stuck.phase).withContext('静止超过 60 秒才算结束').toBe('progressOnly');
+    /*
+     * 🔴 2026-11-11 口径再调整(用户裁决「应该保持到会话结束」):
+     * 静止 90 秒**不再**判结束 —— 容器跑 2 分钟长任务是正常的。
+     * 只有到 180 秒用户级超时才收尾(异常态,页面弹「容器无响应」)。
+     */
+    const [stillRunning] = turnsFromDm([turnOf()], T0 + 90_000, false);
+    expect(stillRunning.phase).withContext('没有结束信号就继续输出中').toBe('replying');
+
+    /*
+     * 180 秒后**仍不判结束** —— 状态如实显示,异常由 `isTurnStalled` 弹提示。
+     * (见下面「长任务宽限」用例的完整说明。)
+     */
+    const [timedOut] = turnsFromDm([turnOf()], T0 + 185_000, false);
+    expect(timedOut.phase).withContext('没有结束信号就继续如实显示').toBe('replying');
   });
 
   it('什么都没有 → idle', () => {
@@ -625,13 +703,13 @@ describe('phaseOf(交付口径)', () => {
 describe('isTurnTimedOut', () => {
   const MIN = 60_000;
 
-  it('超过 3 分钟且一个字没回 → 超时', () => {
-    expect(isTurnTimedOut(181_000, false, false)).toBe(true);
+  it('超过 5 分钟且一个字没回 → 超时', () => {
+    expect(isTurnTimedOut(301_000, false, false)).toBe(true);
     expect(isTurnTimedOut(TURN_TIMEOUT_MS, false, false)).toBe(true);
   });
 
-  it('未到 3 分钟 → 不算超时', () => {
-    expect(isTurnTimedOut(179_000, false, false)).toBe(false);
+  it('未到 5 分钟 → 不算超时', () => {
+    expect(isTurnTimedOut(299_000, false, false)).toBe(false);
   });
 
   it('🔴 已经有输出 → 不算超时(容器干活慢不等于异常)', () => {
@@ -658,8 +736,16 @@ describe('isTurnStalled(卡住检测)', () => {  it('有输出、已停止
     expect(isTurnStalled(59_000, true, false)).toBe(false);
   });
 
-  it('🔴 仍在 live → 属于正常思考,不判卡住', () => {
-    expect(isTurnStalled(10 * 60_000, true, true)).toBe(false);
+  it('🔴 live 但长时间没有新增 → 同样要提示(2026-11-11 口径定稿)', () => {
+    /*
+     * 旧判据 `if (live) return false` 是基于「live 说明还在动」的假设。
+     * 但 `live` 现在的语义是「还没收到结束信号」—— 长任务卡死时它一直为真,
+     * 于是「容器无响应」永远不弹,用户只能干等。
+     *
+     * 新判据只看**有没有新东西**:有输出 + 长时间无新增 = 该提醒了(只是提示,不改状态)。
+     */
+    expect(isTurnStalled(10 * 60_000, true, true)).withContext('卡死要提示').toBe(true);
+    expect(isTurnStalled(5_000, true, true)).withContext('刚有新增不打扰').toBe(false);
   });
 
   it('🔴 一个字都没回 → 交给 isTurnTimedOut,两个判据不重叠', () => {
@@ -699,10 +785,87 @@ describe('🔴 长任务宽限:有过程步骤时不应过早判已结束(20
     expect(t.phase).withContext('不能说「已结束」').toBe('replying');
   });
 
-  it('静止 70 秒(超过 60s 宽限)→ 才判已结束', () => {
+  /*
+   * 🔴 2026-11-11 口径定稿(用户裁决「应该保持到会话结束」)。
+   *
+   * 结束**只看容器信号**(✅/❌ reaction),不靠时间猜。
+   * 只要这一轮有过程步骤、容器没说结束,就一直算「输出中」,
+   * 唯一的出口是 180 秒用户级超时(那时会弹「容器无响应」异常提示,
+   * 而不是伪装成「已完成」)。
+   */
+  it('🔴 静止 70 秒 + 无结束信号 → 仍算进行中(不靠时间猜结束)', () => {
     const [t] = turnsFromDm([workingTurn()], T0 + 72_000, false);
+    expect(t.blocks[0].live).withContext('没有结束信号就不能说完成').toBeTrue();
+    expect(t.phase).withContext('不能显示「已完成」').toBe('replying');
+  });
+
+  it('🔴 静止 170 秒(长任务)+ 无结束信号 → 仍在进行中', () => {
+    const [t] = turnsFromDm([workingTurn()], T0 + 170_000, false);
+    expect(t.blocks[0].live).withContext('长任务跑 170 秒是正常的,不能判完成').toBeTrue();
+    expect(t.phase).toBe('replying');
+  });
+
+  it('🔴 超过 180 秒 → 状态仍如实显示「输出中」,由 isTurnStalled 弹提示', () => {
+    /*
+     * 2026-11-11 用户裁决:「应该保持到会话结束」。
+     * 超时**不再强行把状态改成已结束** —— 那还是「用时间猜」。
+     * 长任务跑多久就显示多久,同时由页面层弹「容器无响应」提示告知用户。
+     */
+    const now = T0 + 185_000;
+    const [t] = turnsFromDm([workingTurn()], now, false);
+    expect(t.blocks[0].live).withContext('干活期间始终如实显示').toBeTrue();
+    // 但异常提示照常触发:已经有输出、且很久没有新增
+    const idleMs = now - t.blocks[0].updatedAt;
+    expect(isTurnStalled(idleMs, true, t.blocks[0].live)).withContext('要弹「容器无响应」').toBeTrue();
+  });
+
+  it('🔴 收到结束信号 → 立刻收尾,不等 90 秒兜底', () => {
+    const turn = workingTurn();
+    const lastId = turn.blocks[turn.blocks.length - 1].id;
+    const [t] = turnsFromDm([turn], T0 + 30_000, false, 25_000, false, lastId);
+    expect(t.blocks[0].turnEnded).toBeTrue();
     expect(t.blocks[0].live).toBeFalse();
-    expect(t.phase).toBe('progressOnly');
+  });
+
+  /*
+   * 🔴 截图实证场景(2026-11-11 用户反馈):
+   *
+   *   19:16 用户提问「帮我查天气」
+   *   19:16 容器回「收到,我这就去查,大约一分钟内给结论。」
+   *        过程区「运行了 1 步 · Running code … 10s 已完成」
+   *   然后静默 60 秒以上,真正的天气结果才到
+   *
+   * 用户质问:为什么 10s 就显示「已完成」?计时为什么停了?
+   *
+   * 这个场景的特征是「**有工具步骤 + 有一句正文 + 长时间静默**」,
+   * 与纯过程(`progressOnly`)不同 —— 它已经有交付内容了,会被判成 `done`。
+   */
+  it('🔴 截图场景:有步骤 + 一句正文 + 静默 → 收尾前不能说已完成', () => {
+    const shotTurn = (): Turn =>
+      dmTurn(
+        [msg('a1', 'agent', '💻 Running code from hermes_tools\n收到,我这就去查,大约一分钟内给结论。', 1000)],
+        { userMessage: msg('u1', 'me', '帮我查天气', 0), closed: true },
+      );
+    // 装好:确实被拆成「过程 + 正文」两块内容
+    const [pre] = turnsFromDm([shotTurn()], T0 + 12_000, false);
+    expect(pre.blocks[0].steps.length).withContext('要有过程步骤').toBeGreaterThan(0);
+    expect(pre.blocks[0].body.length).withContext('要有正文').toBeGreaterThan(0);
+
+    // 静默 60 秒(超过 STALL_MS)但没收到结束信号 → 仍应算进行中
+    const [t] = turnsFromDm([shotTurn()], T0 + 62_000, false);
+    expect(t.blocks[0].live).withContext('容器还在跑,不能标已完成').toBeTrue();
+    expect(t.phase).withContext('不能显示「已完成」').toBe('replying');
+  });
+
+  it('🔴 截图场景:收到 ✅ reaction 后立刻收尾', () => {
+    const shotTurn = (): Turn =>
+      dmTurn(
+        [msg('a1', 'agent', '💻 Running code from hermes_tools\n收到,我这就去查,大约一分钟内给结论。', 1000)],
+        { userMessage: msg('u1', 'me', '帮我查天气', 0), closed: true },
+      );
+    const [t] = turnsFromDm([shotTurn()], T0 + 62_000, false, 25_000, false, 'a1');
+    expect(t.blocks[0].turnEnded).withContext('✅ 到达后应认领').toBeTrue();
+    expect(t.blocks[0].live).withContext('收到结束信号才收尾').toBeFalse();
   });
 
   it('没有任何步骤 → 仍按原静默窗口收尾(宽限只给真在干活的)', () => {

+ 180 - 14
src/app/core/assistant-turn.ts

@@ -86,6 +86,13 @@ export interface TurnBlockView {
   files: { key: string; file: FileCard }[];
   /** 本块是否仍在输出 */
   live: boolean;
+  /**
+   * 容器是否**明确宣告本轮结束**(2026-11-11)。
+   *
+   * 为 true 时 `live` 必定为 false —— 结束信号优先于所有静默窗口。
+   * 渲染层可据此把「已完成」写得更肯定(可选,不影响原有判据)。
+   */
+  turnEnded?: boolean;
   /**
    * 是否仍在**等第一口气**(2026-11-11)。
    *
@@ -180,6 +187,17 @@ interface BlockSource {
   updatedAt: number;
   typing: boolean;
   closed: boolean;
+  /**
+   * 容器**明确宣告本轮结束**(2026-11-11)。
+   *
+   * 来源:容器在 `post_llm_call` 发 `m.typing=false`,App 侧
+   * `chat.service` 记录该事件的时刻,再按「是否晚于本块末条消息」判定。
+   *
+   * 语义与 `!typing` 不同:`typing=false` 只是「此刻没在打字」,
+   * 两条消息之间的空档也会出现;本字段要求信号**确实在本轮末条消息之后**,
+   * 才算「这一轮干完了」。
+   */
+  turnEnded?: boolean;
 }
 
 /* ------------------------------------------------------------------ */
@@ -501,16 +519,33 @@ function proseOf(text: string): boolean {
 /**
  * 判定「超时」的时长门槛。
  *
- * 用户裁决(2026-11-10 第二次调整):**发出消息后 3 分钟内没有任何回复**才算异常。
+ * ## 演进
  *
- * 演进:5 分钟太长(干等)→ 1 分钟太短(容器跑长任务时误报,用户实测被误判)
- *      → 定为 3 分钟。容器正常回一轮 10~60 秒,但要跑任务时可能几分钟。
+ * | 时间 | 值 | 原因 |
+ * |---|---|---|
+ * | 2026-11-10 | 5 分钟 | 初值 |
+ * | 2026-11-10 | 3 分钟 | 5 分钟太长(干等),1 分钟太短(误报)→ 折中 |
+ * | **2026-11-11** | **5 分钟** | 见下(**语义变了,不能直接沿用旧结论**) |
+ *
+ * ## 为什么又回到 5 分钟
+ *
+ * 旧结论「5 分钟太长」成立的前提是:**超时会强行把界面改成「已结束」**,
+ * 用户干等 5 分钟看到的是错误的完成状态,所以越短越好。
+ *
+ * 2026-11-11 用户裁决「应该保持到会话结束」之后,这个前提没了:
+ *   · 超时**不再改写状态** —— 长任务跑多久就如实显示多久「输出中」
+ *   · 超时只决定**什么时候弹一条提示**,告诉用户「好像有点久」
  *
- * ⚠️ 注意:这个值同时参与 `waitingTooLong`(超时后 `live` 转 false、占位消失)。
- *    改大意味着占位会多挂一段时间,这是有意的 —— 用户宁可看「正在思考」
- *    也不愿意 1 分钟就被告知「异常」。
+ * 在这种情况下,短反而有害:
+ *   · 容器自己实测跑过 3 分钟以上(它还说过「3 分钟了,值得等」)
+ *   · 180 秒会在正常长任务上频繁误报,把提示变成狼来了
+ *
+ * 5 分钟能覆盖实测到的长任务,同时容器真挂了也不会让用户无限茫然。
+ *
+ * @see isTurnStalled —— 另一条提示路径:**有输出但长时间无新增**,
+ *      门槛是 `STALL_MS`(60 秒),与这里互补,不会重复打扰。
  */
-export const TURN_TIMEOUT_MS = 180_000;
+export const TURN_TIMEOUT_MS = 300_000;
 
 const DEFAULT_QUIET_MS = 25_000;
 
@@ -582,6 +617,18 @@ function toBlockView(
    */
   const waitingTooLong =
     awaitingFirstReply && waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
+  /*
+   * 🔴 用户级超时(2026-11-11 口径定稿后必须对**所有**未结束回合生效)。
+   *
+   * 结束判据改成「只看容器信号」之后,`live` 会在容器不吭声时一直为真。
+   * 如果只有「还没回第一条」的回合能超时,那么「回了一半、容器挂了」的回合
+   * 会永远转圈 —— 必须有出口,否则是把「提前完成」换成了「永远转圈」。
+   *
+   * 这是**异常态**出口:页面层据此弹「容器无响应」提示,
+   * 与「正常完成」在语义上分得清清楚楚。
+   */
+  const hardTimedOut =
+    waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
   /*
    * 🔴 长任务宽限(2026-11-11 用户实测修复)。
    *
@@ -598,6 +645,33 @@ function toBlockView(
    */
   const hasSteps = steps.length > 0;
   const graceMs = hasSteps ? Math.max(quietMs, STALL_MS) : quietMs;
+  /*
+   * 🔴 「先显示已完成、过很久正文才出来」(2026-11-11 用户实测第二大问题)。
+   *
+   * ## 现象
+   *
+   * 用户问天气 → 容器先回一句「我先查一下这三地的实时天气。」→ 然后跑工具、
+   * 几十秒后才发真正的结果。第一条消息到达时静默窗口开始倒计时,宽限一过
+   * 就被判成 `done`(「已完成」),用户看到「已完成」却没有答案。
+   *
+   * ## 为什么不能只靠时间窗口
+   *
+   * 容器是**分条发送**的:它可能在任何时刻停顿 60 秒以上(跑长工具、查网络)。
+   * 从「有没有消息」推断「有没有干完」,本质上信息不足。
+   *
+   * ## 现在的判据(三档,优先级从高到低)
+   *
+   *   ① 容器**明确宣告结束**(`turnEnded`:✅ reaction / typing=false)
+   *      → 立即收尾,这是唯一「确定干完了」的信号
+   *   ② 没有任何结束信号,但**本轮已经从用户提问算起跑完了整个超时窗口**
+   *      → 才允许判结束(兜底老容器,行为与改造前一致)
+   *   ③ 其余情况 → 保持「输出中」,不提前说完成
+   *
+   * 这样长任务不会再出现「先完成后输出」:只要容器没说结束,
+   * 界面就一直如实显示在干活。
+   */
+  const silentLongEnough =
+    waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
   /*
    * 🔴 `src.closed` 在有步骤时也要让路。
    *
@@ -609,8 +683,72 @@ function toBlockView(
    *        **有步骤时**以时间为准(宽限到 STALL_MS)。
    */
   const closedBlocks = !hasSteps && src.closed;
+  /*
+   * 🔴 容器明确宣告结束(2026-11-11,`turnEnded`)—— **优先级高于一切时间窗口**。
+   *
+   * 实测(`scripts/.probe-typing.mjs`,lj-test):容器 16.8 秒就把正文发完并打了 ✅,
+   * 但整轮 105 秒发不出一条结束信号,App 只能等 25 秒(有步骤时 60 秒)静默窗口,
+   * 于是「20 秒的活」显示到 50 多秒还在「输出中」(用户实测截图)。
+   *
+   * 现在容器侧插件在 `post_llm_call` 发 `m.typing=false`,App 侧收到即停:
+   *   · 有结束信号 → `live` 立刻为 false,读数定格在末条消息时刻
+   *   · 无结束信号(未改造容器)→ 照旧走下面的静默窗口兜底,行为不变
+   *
+   * ⚠️ 只认「**确认结束**」,不认「没在打字」。容器在两条消息之间的空档也可能发
+   *    `typing=false`(本轮结束刷新),若把它当成结束,长工具跑一半就会被切断。
+   *    所以信号必须带**轮次标识**,由 `turnEndedFor()` 按「是否晚于本块末条消息」判定。
+   */
+  const turnEnded = src.turnEnded === true;
+  /*
+   * 🔴 「还没收到结束信号」→ 不许判完成(2026-11-11)。
+   *
+   * 这是「先完成后输出」的根治点:容器分条发送时,第一条消息到达后
+   * 静默窗口就开始了,宽限一过就显示「已完成」,而真正的答案还在后面。
+   *
+   * 判据:**有消息、没结束信号、整个超时窗口还没跑完** → 继续算「输出中」。
+   *   · 老容器(无信号):最多多挂到 180 秒超时,之后照常收尾 → 不会永远转
+   *   · 新容器:收到 ✅/typing=false 立刻收尾 → 不残留
+   *
+   * 注意这里**不覆盖** `turnEnded` 和 `waitingTooLong`:
+   * 有结束信号就正常结束;超过 180 秒必须有出口,否则卡片永远停不下来。
+   */
+  /*
+   * 🔴 结束以**容器信号**为准,不以时间猜(2026-11-11 用户裁决)。
+   *
+   * 用户原话:「为什么保持到 90s,应该保持到会话结束」。
+   *
+   * 他说得对。90 秒是我拍的兜底值,本质还是「用时间猜」——
+   * 容器跑 2 分钟的长任务,到 90 秒照样会被标成「已结束」,
+   * 这正是用户一直在抱怨的那个毛病。把 60 秒换成 90 秒,只是把问题推迟了 30 秒。
+   *
+   * 正确判据只有一条:**容器说结束(✅/❌ reaction)才算结束**。
+   * 在那之前,只要这一轮**确实在干活**(有过程步骤),就一直算「输出中」。
+   *
+   * 唯一的出口是用户级超时 `TURN_TIMEOUT_MS`(180 秒):
+   *   · 它由页面层弹「容器无响应」提示,是**明确的异常态**,不是"正常完成"
+   *   · 没有它,容器真挂了界面会永远转圈
+   *
+   * ⚠️ 只在**有过程步骤**时启用:没有步骤的纯文本回合(容器只回一句话),
+   *    仍按静默窗口收尾 —— 那种回合发完就是发完了,不该无限挂着。
+   */
+  const heldOpenByNoSignal =
+    hasSteps && !turnEnded && waitingSince > 0;
+  /*
+   * 🔴 超时**不再强行改写状态**(2026-11-11 用户裁决「应该保持到会话结束」)。
+   *
+   * 上一版把 `hardTimedOut` 直接塞进 `live`,到点就把回合从「输出中」改成
+   * 「已结束」—— 这跟「60 秒猜完成」是同一个错误,只是把猜的时间点挪后。
+   * 用户要的是:**干活期间始终如实显示在干活**。
+   *
+   * 现在超时的作用只剩一个:**让页面层弹「容器无响应」提示**
+   * (`isTurnTimedOut` 消费 `waitMs`),状态本身继续如实显示。
+   * 用户看到「跑了 8 分钟 + 顺便弹个提示」比「跑到 3 分钟突然说完成」合理得多。
+   */
   const live =
-    !closedBlocks && !waitingTooLong && (awaitingFirstReply || typingAlive || idleFor < graceMs);
+    !closedBlocks &&
+    !(waitingTooLong && !hasSteps) &&
+    !turnEnded &&
+    (awaitingFirstReply || typingAlive || idleFor < graceMs || heldOpenByNoSignal);
   /*
    * 「还在等回复」——与 `live` **解耦**(2026-11-11 用户实测修复)。
    *
@@ -672,6 +810,7 @@ function toBlockView(
     body,
     files,
     live,
+    turnEnded,
     waiting,
     empty: !live && !waiting && body.length === 0 && steps.length === 0,
     merged: mergeBodies([{ body }]),
@@ -732,10 +871,15 @@ export function elapsedSeconds(block: TurnBlockView, now: number): number {
    * 但 `updatedAt` 只到「最后一条 agent 消息」,比用户实际等的短:
    * 用户等了 56 秒,一结束读数瞬间掉回 20 秒,看起来像计时坏了。
    *
-   * 现在:结束时**定格在「末条活动 + 一个收尾宽限」与「当前时刻」的较小值**。
-   *   · 宽限覆盖「容器发完最后一条消息 → 界面感知到结束」之间的几百毫秒
-   *   · 取 `min(now, …)` 保证不会超过用户真正等待的时间
-   *   · 结束后时钟已停,读数不再变化(定格语义保留)
+   * 🔴 2026-11-11 二次修正:有**结束信号**时定格在末条消息时刻。
+   *
+   * 容器侧插件现在会发 `m.typing=false`,所以「正文发完」到「界面收尾」之间
+   * 不再有 25/60 秒的空转。此时若仍读到 `now`,末条消息之后到信号到达的那一两秒
+   * 会被算进读数,与 `seconds`(容器真实处理耗时)对不上。
+   * 有信号时用 `updatedAt`(末条消息),读数即为容器真实耗时,精确且不再倒退。
+   *
+   * 只有在**没有结束信号**(未改造的老容器)时,才保留「用户体感时长」口径 ——
+   * 因为那种情况下收尾是猜的,读数取 `now` 才不会在判定结束前突然跳跃。
    */
   if (!block.live) {
     if (block.updatedAt <= block.startAt) return block.seconds;
@@ -850,8 +994,17 @@ export function isTurnStalled(
   live: boolean,
   stallMs = STALL_MS,
 ): boolean {
-  /* 还在 live = 静默窗口内,属于「正常思考中」,不打扰 */
-  if (live) return false;
+  /*
+   * 🔴 2026-11-11 口径定稿后修正:**不再要求 `!live`**。
+   *
+   * 旧判据假设「live 说明还在动」,但现在 `live` 的语义变成了
+   * 「这一轮还没收到结束信号」—— 长任务卡死时它会一直为真,
+   * 于是「容器无响应」提示永远不会弹,用户只能干等。
+   *
+   * 正确的判据是**有没有新东西进来**:已经有输出、但连续 `stallMs` 毫无新增,
+   * 无论 live 与否都该提醒用户「可能卡住了」。
+   * 它只是**提示**,不改状态 —— 容器真有动静了,下一次渲染提示自然消失。
+   */
   /* 一个字都没回的由 `isTurnTimedOut` 负责,两处不重叠 */
   if (!hasOutput) return false;
   return idleMs >= stallMs;
@@ -925,6 +1078,8 @@ export function turnsFromGroup(
  * @param quietMs  「还在输出」的静默窗口,调用方传 `QUIET_MS`
  * @param agentIdle 容器**明确上报**的空闲态。为 true 时忽略静默窗口直接收尾 ——
  *                  只有容器能宣布「我干完了」,比任何时间窗都准。
+ * @param turnEndedAfter 收到容器 `m.typing=false` 时,本会话最后一条 agent 消息的 id
+ *                       (null = 无信号)。只有「本块末条消息就是这个 id」才认它结束。
  */
 export function turnsFromDm(
   turns: Turn[],
@@ -932,6 +1087,7 @@ export function turnsFromDm(
   typing: boolean,
   quietMs = DEFAULT_QUIET_MS,
   agentIdle = false,
+  turnEndedAfter: string | null = null,
 ): AssistantTurn[] {
   return turns.map((t) => {
     /*
@@ -1000,6 +1156,16 @@ export function turnsFromDm(
               updatedAt: last,
               typing,
               closed: t.closed,
+              /*
+               * 结束信号按**消息 id** 认领(2026-11-11):
+               * 收到 `typing=false` 时记下的末条 agent 消息 id,必须正好是本块的末条。
+               * 不跨时钟比较(服务端 ts vs 本地时钟会差 1~2 秒),
+               * 也天然挡住了「上一轮的信号误伤这一轮」。
+               */
+              turnEnded:
+                turnEndedAfter != null &&
+                msgs.length > 0 &&
+                msgs[msgs.length - 1]?.id === turnEndedAfter,
             },
             now,
             agentIdle ? 0 : quietMs,

+ 101 - 0
src/app/core/chat.service.ts

@@ -155,6 +155,30 @@ export class ChatService {
   private readonly _uploadPhase = signal<UploadPhase>('idle');
   private readonly _draft = signal('');
   private readonly _peerTyping = signal(false);
+  /**
+   * 容器发 `m.typing=false` 的**本地接收时刻**(epoch ms,0 = 本次会话还没收到过)。
+   *
+   * 2026-11-11:容器侧插件在 `post_llm_call` 发 `m.typing=false` 当作「本轮结束」信号。
+   * App 侧据此立刻收尾,不再死等 25/60 秒静默窗口。
+   */
+  private readonly _turnEndedAt = signal(0);
+  readonly turnEndedAt = this._turnEndedAt.asReadonly();
+  /**
+   * 收到 `typing=false` 那一刻,本会话**最后一条 agent 消息的 id**(2026-11-11)。
+   *
+   * 🔴 为什么不能只存时刻、靠比较时间戳:
+   *
+   * 消息的 `ts` 来自 **Matrix 服务端**(`origin_server_ts`),而信号记的是
+   * **本地时钟**。两台机器差 1~2 秒是常态 —— 服务端稍快时,
+   * 「信号时刻 ≥ 末条消息时刻」永远不成立,结束信号**彻底失效**;
+   * 服务端稍慢时,又会把上一轮的信号误认成这一轮的。
+   *
+   * 所以改成**按消息 id 认领**:信号到达时记下此刻的末条 agent 消息 id,
+   * 回合判定时只问一句「本块的末条消息是不是就是这一条」—— 同一份数据内部比较,
+   * 不跨时钟,天然免疫偏差。
+   */
+  private readonly _turnEndedAfter = signal<string | null>(null);
+  readonly turnEndedAfter = this._turnEndedAfter.asReadonly();
 
   /**
    * 「文件名|大小」→ 云盘地址(2026-11-10)。
@@ -281,6 +305,8 @@ export class ChatService {
   /** 事件订阅解绑函数(切会话必须调用,防串扰) */
   private unbindMessage: (() => void) | null = null;
   private unbindTyping: (() => void) | null = null;
+  /** 容器 ✅/❌ reaction 订阅解绑(2026-11-11,本轮结束信号) */
+  private unbindTurnReaction: (() => void) | null = null;
   /** 全局未读监听解绑(仅登出/销毁时调用) */
   private unbindUnread: (() => void) | null = null;
   /**
@@ -506,6 +532,8 @@ export class ChatService {
     this._messages.set([]);
     this._draft.set('');
     this._peerTyping.set(false);
+    this._turnEndedAt.set(0);
+    this._turnEndedAfter.set(null);
 
     try {
       await this.live.ensureLoaded();
@@ -670,6 +698,8 @@ export class ChatService {
     this._error.set(null);
     this._hasMoreHistory.set(false);
     this._peerTyping.set(false);
+    this._turnEndedAt.set(0);
+    this._turnEndedAfter.set(null);
     this._blankWorkspace = workspaceId;
     // 新建态标记:storeVersion effect 据此拒绝回填(见字段注释)
     this._blankSessionId = s.id;
@@ -747,7 +777,60 @@ export class ChatService {
        */
       if (myUserId && who === myUserId) return;
       this._peerTyping.set(typing);
+      /*
+       * 容器从「正在输入」转成「没在输入」= **本轮结束信号**(2026-11-11)。
+       *
+       * 容器侧插件在 `post_llm_call` 发 `m.typing=false`,这是它在 Matrix 里
+       * 唯一能可靠发出的「我干完了」。此前容器整轮一条 typing 都不发,
+       * App 只能等 25/60 秒静默窗口,20 秒的活显示到 50 多秒还在「输出中」。
+       *
+       * 🔴 记的是「此刻最后一条 agent 消息的 id」,不是时刻 —— 见
+       *    `_turnEndedAfter` 的注释:跨时钟比较会被服务端/本地时差打死。
+       */
+      if (!typing) {
+        this._turnEndedAt.set(Date.now());
+        this._turnEndedAfter.set(this.lastAgentMessageId());
+      }
     });
+    /*
+     * 容器的 ✅ / ❌ reaction = **本轮真的干完了**(2026-11-11)。
+     *
+     * 这是比 typing 更硬的信号:实测容器每轮处理结束都会把 👀 换成 ✅,
+     * 由 Hermes 内建行为发出,不依赖插件改造。
+     *
+     * 用户痛点:「先显示已完成,过很久正文才出来」——容器分条发送时,
+     * 第一条消息到达时静默窗口倒计时就开始了,而真结果还在后面。
+     * 用 reaction 认领后,只有容器真正收尾才判结束。
+     */
+    this.unbindTurnReaction = this.mx.onTurnReaction?.(rid, (_ok, sender) => {
+      if (my !== this.seq) return;
+      /*
+       * 🔴 必须校验 sender(2026-11-11,容器侧代码取证指出)。
+       *
+       * 群聊里 @ 了多个容器时,**每个容器各打一个 ✅、打在同一个 event_id 上**。
+       * 若不看 sender,A 容器收工会被当成 B 容器也收工 —— 界面提前收尾、
+       * 用户以为 B 挂了。
+       *
+       * 单聊只有本容器一个 agent,这条校验恒为真、不影响原有行为。
+       */
+      if (agentMxId && sender && sender !== agentMxId) return;
+      /*
+       * 🔴 稍等一拍再取「末条 agent 消息 id」(2026-11-11)。
+       *
+       * reaction 与它标注的消息是两个独立事件,到达顺序**不保证**:
+       * 实测 +15.3s 出正文、+15.4s 出 ✅,但也有网络乱序时 ✅ 先到的情形。
+       * 若在那一刻就取 id,可能取到上一条,导致本轮永远等不到结束信号。
+       *
+       * 让出一拍(`setTimeout 0` 不够,消息要过一遍 signal 更新)再取,
+       * 代价是收尾晚几十毫秒,用户完全无感。
+       */
+      const stamp = () => {
+        if (my !== this.seq) return;
+        this._turnEndedAt.set(Date.now());
+        this._turnEndedAfter.set(this.lastAgentMessageId());
+      };
+      setTimeout(stamp, 300);
+    }) ?? null;
 
     /*
      * ⑤ 读当前 timeline —— 先确定会话,再按会话(thread)过滤,**只取尾部一屏**。
@@ -1121,6 +1204,8 @@ export class ChatService {
     this._connection.set('disconnected');
     this._draft.set('');
     this._peerTyping.set(false);
+    this._turnEndedAt.set(0);
+    this._turnEndedAfter.set(null);
     this._paginating.set(false);
     this._hasMoreHistory.set(false);
     this._blankWorkspace = null;
@@ -2538,6 +2623,20 @@ export class ChatService {
     };
   }
 
+  /**
+   * 当前会话**最后一条 agent 消息**的 id(2026-11-11)。
+   *
+   * 供结束信号(`m.typing=false`)做「这一段到哪为止」的认领用。
+   * 没有 agent 消息时返回 null —— 此时信号没有可绑定的段落,不参与结束判定。
+   */
+  private lastAgentMessageId(): string | null {
+    const list = this._messages();
+    for (let i = list.length - 1; i >= 0; i--) {
+      if (list[i].sender === 'agent') return list[i].id;
+    }
+    return null;
+  }
+
   /** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
   private appendIncoming(m: MxMessage, myUserId: string): void {
     if (!this._agentId()) return;
@@ -2644,8 +2743,10 @@ export class ChatService {
   private teardownSubscriptions(): void {
     this.unbindMessage?.();
     this.unbindTyping?.();
+    this.unbindTurnReaction?.();
     this.unbindMessage = null;
     this.unbindTyping = null;
+    this.unbindTurnReaction = null;
   }
 
   private markSent(id: string): void {

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

@@ -1106,6 +1106,54 @@ export class MatrixClientService implements OnDestroy {
     };
   }
 
+  /**
+   * 订阅容器的 **✅ / ❌ reaction**(2026-11-11)。
+   *
+   * ## 为什么它是一个可靠的本轮结束信号
+   *
+   * 实测(lj-test,`adapter.py:2331 on_processing_complete()`):
+   * 容器**每一轮**处理结束都会把 👀 换成 ✅(成功)或 ❌(失败),
+   * `CANCELLED` 例外不发。这是 Hermes 的内建行为,不依赖任何插件改造,
+   * 比 `m.typing` 更不容易丢(typing 是 ephemeral,且可能被配置关掉)。
+   *
+   * ## 与 typing 的分工
+   *
+   *   · reaction ✅ = **这一轮真的干完了**(含工具跑完、正文发完)
+   *   · typing=false = 「此刻没在打字」,中途空档也会有
+   *
+   * 所以收尾判据优先用 reaction;typing 作为辅助。
+   *
+   * @param cb 收到 ✅/❌ 时回调,参数为 true 表示成功、false 表示失败;
+   *           第二个参数是发 reaction 的 mxid(群聊用来区分是哪个容器收工的)
+   */
+  onTurnReaction(roomId: string, cb: (ok: boolean, sender: 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 key = rel?.key ?? '';
+      /*
+       * 🔴 必须把 sender 传出去(2026-11-11 容器侧代码取证指出)。
+       *
+       * 群聊里 @ 了多个容器时,**每个容器各打一个 ✅,且打在同一个 event_id 上**。
+       * 只按「房间里有 ✅ 就算结束」会把其他成员的 ✅ 误认成本轮的结束,
+       * 导致某个容器还在跑、界面却已经收尾。
+       *
+       * 调用方要用 sender 和「本块对应的 agent」比对,见 `chat.service`。
+       */
+      if (key === '✅') cb(true, ev.getSender() ?? '');
+      else if (key === '❌') cb(false, ev.getSender() ?? '');
+    };
+    c.on('Room.timeline' as never, handler as never);
+    return () => {
+      try {
+        c.off('Room.timeline' as never, handler as never);
+      } catch {
+        /* 忽略 */
+      }
+    };
+  }
+
   /** 订阅对方 typing 状态;返回解绑函数 */
   onTyping(roomId: string, cb: (typing: boolean, userId: string) => void): () => void {
     const c = this.requireClient();

+ 47 - 0
src/app/core/task-pulse.service.spec.ts

@@ -129,6 +129,53 @@ describe('TaskPulseService', () => {
     expect(line.text).not.toContain('reporter');
   });
 
+  /*
+   * 🔴 会话页只显示**本会话**的进度(2026-11-11 用户裁决)。
+   *
+   * 实测截图:打开会话 A,页头显示「已完成 · 用时 10 分 16 秒」,
+   * 而那个时长来自容器**全局** status —— 容器当时正在跑会话 B。
+   * 用户要求:「右上角仅显示当前会话的进度,如果当前会话没有就显示在线状态」。
+   */
+  describe('会话维度(传 sessionId)', () => {
+    it('🔴 容器在跑别的会话 → 本会话显示「在线」,不显示别人的任务', () => {
+      const line = svc.describe(
+        agent({
+          status: 'running',
+          currentTask: '读一个 20MB 文件',
+          currentSessions: [{ sessionKey: 'agent:main:matrix:dm:!roomB:sess-B:fmode.cn', status: 'running' }],
+        }),
+        Date.now(),
+        'sess-A',
+      );
+      expect(line.text).toBe('在线');
+      expect(line.active).toBeFalsy();
+    });
+
+    it('本会话在跑 → 显示容器任务名', () => {
+      const line = svc.describe(
+        agent({
+          status: 'running',
+          currentTask: '写周报',
+          currentSessions: [{ sessionKey: 'agent:main:matrix:dm:!roomA:sess-A:fmode.cn', status: 'running' }],
+        }),
+        Date.now(),
+        'sess-A',
+      );
+      expect(line.text).toBe('写周报');
+      expect(line.active).toBeTrue();
+    });
+
+    it('🔴 容器没上报会话级状态(老容器)→ 回落全局口径,行为不变', () => {
+      const line = svc.describe(agent({ status: 'running', currentTask: '写周报' }), Date.now(), 'sess-A');
+      expect(line.text).toBe('写周报');
+    });
+
+    it('不传 sessionId(列表页)→ 仍走全局口径', () => {
+      const line = svc.describe(agent({ status: 'running', currentTask: '写周报' }));
+      expect(line.text).toBe('写周报');
+    });
+  });
+
   it('心跳新鲜但容器说 error → 不显示「空闲」(先报坏)', () => {
     const line = svc.describe(agent({ status: 'error' }));
     expect(line.text).not.toBe('空闲');

+ 53 - 6
src/app/core/task-pulse.service.ts

@@ -2,7 +2,7 @@ import { computed, effect, inject, Injectable, signal } from '@angular/core';
 import { Agent } from './api/models';
 import { LiveService } from './live.service';
 import { QUIET_MS } from './turn-aggregator';
-import { isReporterSelfTalk, HEARTBEAT_STALE_SEC, resolveAgentState } from './agent-state';
+import { isReporterSelfTalk, HEARTBEAT_STALE_SEC, resolveAgentState, sessionRunning } from './agent-state';
 
 /**
  * 服务端数据「新鲜」窗口(毫秒)。
@@ -195,14 +195,14 @@ export class TaskPulseService {
   }
 
   /** 「当前任务」的一句话文案(唯一实现,列表/头部/右栏都用它) */
-  describe(agent: Agent | undefined, now = Date.now()): TaskLine {
+  describe(agent: Agent | undefined, now = Date.now(), sessionId?: string | null): TaskLine {
     // 依赖这些信号,让调用方在数据更新后重算
     this._localStart();
     this.live.tick();
-    return this.describeOf(agent, now);
+    return this.describeOf(agent, now, sessionId);
   }
 
-  private describeOf(agent: Agent | undefined, now: number): TaskLine {
+  private describeOf(agent: Agent | undefined, now: number, sessionId?: string | null): TaskLine {
     if (!agent) return { text: '', tone: 'neutral' };
     const local = this._localStart().get(agent.id);
     /*
@@ -239,7 +239,50 @@ export class TaskPulseService {
         active: true,
         since: local,
       };
-    }    /*
+    }
+    /*
+     * 🔴 会话页:**只看本会话的进度**(2026-11-11 用户裁决)。
+     *
+     * 现象:打开会话 A,页头显示「已完成 · 用时 10 分 16 秒」,而这个数字来自
+     * 容器**全局**的 `status` / `currentTask` —— 容器同一时刻可能正在跑会话 B,
+     * 用户看到的却是别人的进度(实测截图)。
+     *
+     * 容器在 `pre_llm_call`/`post_llm_call` 里按会话维护 `extra.currentSessions`
+     * (`{sessionKey,status,turnStartAt}`),App 侧 `sessionRunning()` 已经能按
+     * `sessionId` 查出本会话状态。所以:
+     *
+     *   · 本会话 running         → 显示任务(容器全局任务名只在**本会话确实在跑**时采信)
+     *   · 本会话明确不在跑       → 不再引用全局 `status`/`lastFinish`,直接「在线」
+     *   · 容器没上报会话级状态   → 返回 undefined,回落到原有全局口径(老容器兜底)
+     *
+     * 用户原话:「右上角仅显示当前会话的进度,如果当前会话没有就显示在线状态」。
+     */
+    const sessionAware = sessionId !== undefined && sessionId !== null;
+    /*
+     * 容器**有**会话级上报,我才敢拿它下结论。
+     * `currentSessions` 为空 = 老容器从没报过 → `mine` 只能是 undefined,
+     * 此时回落到下面的全局口径,行为与改造前完全一致。
+     */
+    const hasSessionReport = (agent.currentSessions?.length ?? 0) > 0;
+    const mine = sessionAware ? sessionRunning(agent.currentSessions, sessionId) : undefined;
+    if (sessionAware && hasSessionReport && mine !== true) {
+      /*
+       * 本会话没在跑 —— 两种情况都归到这里:
+       *   · `mine === false`:容器明确报了本会话 idle
+       *   · `mine === undefined`:容器报了别的会话、**没报本会话**
+       *     (`sessionRunning` 约定 undefined = 不知道;但在「页面已指定会话」
+       *     的语义下,容器报了一堆会话却唯独没有我这条,就是**我没有在跑**)
+       *
+       * 但上报是分钟级的,刚发完消息时容器可能还没更新 —— 用「最近有消息进来」
+       * 兜住,避免正文还在流式输出就显示「在线」。
+       */
+      const quietMs = now - this.lastActivity(agent.id);
+      if (quietMs < REPLY_QUIET_MS) {
+        return { text: '正在回复…', tone: 'info', active: true };
+      }
+      return { text: '在线', tone: 'success' };
+    }
+    /*
      * 容器已经回 idle,但可能**还在往外发消息**(用户实测:还在回复就显示「已完成」)。
      *
      * 容器的运行态是分钟级上报的,turn 结束与最后一条消息之间可能差几十秒;
@@ -250,7 +293,11 @@ export class TaskPulseService {
     if (quietMs < REPLY_QUIET_MS) {
       return { text: '正在回复…', tone: 'info', active: true };
     }
-    if (agent.status === 'running' && !isReporterSelfTalk(agent)) {
+    /*
+     * `mine === true` 表示**本会话确实在跑**,此时才采信容器的任务名;
+     * 没上报会话级状态(老容器)仍按全局 `status` 判断,行为不变。
+     */
+    if ((mine === true || (mine === undefined && agent.status === 'running')) && !isReporterSelfTalk(agent)) {
       const task = agent.currentTask?.trim() || agent.currentAction?.trim();
       return { text: task || '正在处理', tone: 'success', active: true };
     }

+ 86 - 4
src/app/desktop/pages/chat/chat.ts

@@ -267,7 +267,12 @@ interface ConversationRow {
                     [class]="'tone-' + taskLine(a.id).tone"
                     [class.busy]="taskLine(a.id).active"
                   >
-                    <span class="spin" aria-hidden="true"></span>
+                    <!--
+                      运行态用**旋转 spinner**,其余用静态圆点(2026-11-11 用户裁决)。
+                      原来无论忙不忙都是一颗脉动的小圆点,长任务里看不出"还在转",
+                      用户误以为是卡住了。旋转是"正在工作"的通用语义,比脉动明确。
+                    -->
+                    <span class="spin" [class.spinning]="taskLine(a.id).active" aria-hidden="true"></span>
                     <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
                   </span>
                 }
@@ -1551,10 +1556,50 @@ interface ConversationRow {
         flex-shrink: 0;
         border-radius: 50%;
         background: var(--muted);
+        /* 静止态是实心圆点;运行态切换成 spinner 环(见下方 .spinning) */
+      }
+      /*
+       * 🔴 运行态:真正的旋转加载圈(2026-11-11 用户要求「加一个加载动画」)。
+       *
+       * 原来只是小圆点做 scale 脉动 —— 长任务跑几十秒时,脉动几乎看不出来,
+       * 用户以为卡死了。旋转是「正在工作」的通用语义,一眼就能辨认。
+       * 只动画 transform(合成层,不触发重排),符合 UI 规范 3 的动画纪律。
+       */
+      .task-line .spin.spinning {
+        width: 10px;
+        height: 10px;
+        background: transparent;
+        border: 2px solid var(--edge-strong);
+        border-top-color: var(--success);
+        animation: task-spin 0.75s linear infinite;
+      }
+      .task-line.tone-warning .spin.spinning {
+        border-top-color: var(--warning);
+      }
+      .task-line.tone-danger .spin.spinning {
+        border-top-color: var(--danger);
+      }
+      .task-line.tone-info .spin.spinning,
+      .task-line.tone-success .spin.spinning {
+        border-top-color: var(--success);
+      }
+      @keyframes task-spin {
+        to {
+          transform: rotate(360deg);
+        }
+      }
+      @media (prefers-reduced-motion: reduce) {
+        /* 无障碍:用户要求减少动效时退化为不旋转,仍保留环形标识 */
+        .task-line .spin.spinning {
+          animation: none;
+        }
       }
       .task-line.busy .spin {
-        background: var(--success);
-        animation: pulse-dot 1.4s var(--ease-standard) infinite;
+        /* 保留 busy 的强调色;旋转由 .spinning 负责 */
+        background: transparent;
+      }
+      .task-line:not(.busy) .spin {
+        background: var(--muted);
       }
       .task-line.tone-warning {
         color: var(--warning);
@@ -2137,7 +2182,12 @@ export class DkChatPage implements OnDestroy {
    * 列表行、头部任务条、右栏运行状态**三处共用同一份**,保证左右同步。
    */
   taskLine(agentId: string): TaskLine {
-    return this.pulse.describe(this.live.agentById(agentId));
+    /*
+     * 会话页只看**本会话**的进度(2026-11-11 用户裁决):
+     * 容器同一时刻可能在跑别的会话,拿全局 status 会显示别人的任务。
+     * 本会话没在跑时返回「在线」,而不是容器全局的「已完成 · 用时 …」。
+     */
+    return this.pulse.describe(this.live.agentById(agentId), Date.now(), this.chat.sessionId());
   }
 
   /** 列表行紧凑标签:一次只答一个问题 —— 在不在干活 */
@@ -2210,6 +2260,12 @@ export class DkChatPage implements OnDestroy {
        */
       QUIET_MS,
       this.chat.agentIdle(),
+      /*
+       * 容器结束信号(2026-11-11):容器发 `m.typing=false` 的本地时刻。
+       * 传进去后 `turnsFromDm()` 只在「信号晚于本块末条消息」时才认它结束,
+       * 收到即收尾,不再死等 25/60 秒静默窗口。
+       */
+      this.chat.turnEndedAfter(),
     )) {
       // 用户消息:单独一条,保持单聊原有观感
       if (t.userMessage) {
@@ -2523,8 +2579,34 @@ export class DkChatPage implements OnDestroy {
    *
    * 用户裁决(2026-11-11):「按页面分层」—— 只有会话页额外显示本会话相位,
    * 因为那是用户正在等的反馈。离线时相位一律不显示(不能说明明断了还在回复)。
+   *
+   * 🔴 2026-11-11 二次修正:**必须与回合卡片同源**。
+   *
+   * 用户实测矛盾:右上角「正在回复…」,卡片却写「21s 已完成」且计时停了。
+   * 根因是两处各算各的:
+   *   · 头部原来只看 `peerTyping()` 布尔值 —— 容器持续刷 typing=true 时永远为真
+   *   · 卡片 `live` 受 25 秒静默窗口约束 —— 没有新消息就判完成
+   * 于是同一个回合,两个地方说法相反。
+   *
+   * 现在:**优先用回合自身的 `live`**(那是经过静默窗口/结束信号校准过的判据),
+   * 只有回合还没建立(刚发出去、还没渲染)时才回落到 typing。
    */
   readonly headerPresence = computed(() => {
+    /*
+     * 依赖 clockTick,让回合状态随时间推进而重算(与 aiRows 同口径)。
+     * 没有这一行,`live` 会在某个快照上冻住。
+     */
+    this.clockTick();
+    const turns = this.aiRows();
+    const liveTurn = [...turns].reverse().find((r) => r.kind === 'turn' && r.turn.live);
+    if (liveTurn && liveTurn.kind === 'turn') {
+      const phase = liveTurn.turn.phase === 'replying' ? 'replying' : 'thinking';
+      return sessionPresence(this.chat.agent(), phase);
+    }
+    /*
+     * 没有任何回合在跑:如果我这边刚发完消息、容器还在 typing,
+     * 说明回合对象可能还没建起来(首条消息未到)—— 保留 typing 作为过渡态。
+     */
     const typing = this.chat.peerTyping() ? ('replying' as const) : undefined;
     return sessionPresence(this.chat.agent(), typing);
   });

+ 2 - 0
src/app/mobile/pages/chat/chat.ts

@@ -954,6 +954,8 @@ export class MbChatPage implements OnDestroy {
       /* 与桌面同口径(2026-11-08):25 秒静默窗口 + 容器明确空闲时立即收尾 */
       QUIET_MS,
       this.chat.agentIdle(),
+      /* 容器结束信号(2026-11-11):`m.typing=false` 的本地接收时刻,与桌面同口径 */
+      this.chat.turnEndedAfter(),
     )) {
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });