Prechádzať zdrojové kódy

merge: 文件台账按会话隔离 + 容器成果接入(feat/file-hub-session-isolation)

Huccct 2 hodín pred
rodič
commit
d9e5883885
70 zmenil súbory, kde vykonal 9335 pridanie a 823 odobranie
  1. 4 0
      CLAUDE.md
  2. 353 0
      cloud/functions/fmode-file-hub.js
  3. 236 0
      docs/00-tracking/2026-11-10-交接-未完成事项清单.md
  4. 184 0
      docs/00-tracking/2026-11-10-修改留痕-会话区三缺陷与上云链路.md
  5. 188 0
      docs/00-tracking/2026-11-10-修改留痕-文件台账与按会话隔离.md
  6. 39 0
      docs/00-tracking/操作追踪.md
  7. 204 0
      docs/02-design/plans/2026-11-10-文件上云完整方案.md
  8. 43 0
      docs/03-interface/api/API-契约.md
  9. 121 0
      docs/03-interface/容器任务书-成果按会话归属-20261110.md
  10. 1751 0
      docs/03-interface/容器取证-上云与会话逻辑-20261110.md
  11. 156 0
      docs/03-interface/容器执行-切换文件台账-20261110.md
  12. 383 0
      docs/03-interface/容器执行-成果登记切新函数-20261110.md
  13. 427 0
      docs/03-interface/容器方案-如何可靠拿到threadRoot-20261110.md
  14. 308 0
      docs/03-interface/容器核查-真实会话thread值-20261110.md
  15. 339 0
      docs/03-interface/容器确认-threadRoot可行性-20261110.md
  16. 282 0
      docs/03-interface/容器追问-url为空与会话归属-20261110.md
  17. 164 0
      docs/03-interface/容器追问-产出文件走哪个函数-20261110.md
  18. 35 0
      docs/03-interface/待问容器-上云与会话逻辑-20261110.md
  19. 2 0
      docs/README.md
  20. 1 0
      package.json
  21. 235 0
      scripts/ask-agent.mjs
  22. 78 0
      scripts/ask-ljtest.mjs
  23. 60 2
      scripts/deploy-cloud-fn.mjs
  24. 35 0
      scripts/verify-auto-switch.mjs
  25. 99 0
      scripts/verify-default-upload.mjs
  26. 264 0
      scripts/verify-file-hub.mjs
  27. 28 0
      scripts/verify-live-switch.mjs
  28. 104 0
      scripts/verify-mobile-composer.mjs
  29. 177 0
      scripts/verify-real-upload.mjs
  30. 77 0
      scripts/verify-thread-relation.mjs
  31. 272 0
      scripts/verify-upload.mjs
  32. 6 13
      src/app/app.routes.ts
  33. 26 2
      src/app/app.ts
  34. 10 2
      src/app/components/domain/assistant-turn/assistant-turn.spec.ts
  35. 9 2
      src/app/components/domain/assistant-turn/assistant-turn.ts
  36. 210 0
      src/app/components/feedback/alert-host/alert-host.ts
  37. 107 0
      src/app/core/alert.service.spec.ts
  38. 124 0
      src/app/core/alert.service.ts
  39. 19 0
      src/app/core/api/fmode-api.ts
  40. 35 0
      src/app/core/api/mock-api.ts
  41. 43 0
      src/app/core/api/models.ts
  42. 179 0
      src/app/core/api/parse-api.spec.ts
  43. 74 2
      src/app/core/api/parse-api.ts
  44. 155 5
      src/app/core/assistant-turn.spec.ts
  45. 101 16
      src/app/core/assistant-turn.ts
  46. 6 5
      src/app/core/chat-session.service.ts
  47. 113 4
      src/app/core/chat.service.spec.ts
  48. 279 125
      src/app/core/chat.service.ts
  49. 19 0
      src/app/core/data-tools.ts
  50. 0 72
      src/app/core/declared-files.spec.ts
  51. 0 100
      src/app/core/declared-files.ts
  52. 1 0
      src/app/core/deliverables.service.spec.ts
  53. 87 1
      src/app/core/deliverables.service.ts
  54. 61 114
      src/app/core/device.service.spec.ts
  55. 78 152
      src/app/core/device.service.ts
  56. 49 1
      src/app/core/group-send.service.ts
  57. 48 0
      src/app/core/message-merge.spec.ts
  58. 18 1
      src/app/core/message-merge.ts
  59. 41 0
      src/app/core/message-segments.spec.ts
  60. 35 9
      src/app/core/message-segments.ts
  61. 78 55
      src/app/core/s3-link.spec.ts
  62. 40 0
      src/app/core/s3-link.ts
  63. 41 0
      src/app/core/upload-limits.ts
  64. 234 14
      src/app/desktop/pages/chat/chat.ts
  65. 44 19
      src/app/desktop/pages/group-chat/group-chat.ts
  66. 60 44
      src/app/desktop/shell/desktop-shell.ts
  67. 19 0
      src/app/mobile/components/m-dock/m-dock.ts
  68. 153 12
      src/app/mobile/pages/chat/chat.ts
  69. 47 16
      src/app/mobile/pages/group-chat/group-chat.ts
  70. 37 35
      src/app/mobile/shell/mobile-shell.ts

+ 4 - 0
CLAUDE.md

@@ -20,6 +20,10 @@
 ## 项目补充信息
 
 - 项目名称:**fmode-app-v2** — Fmode 双端客户端(桌面横版 + 手机竖版)· 人机共生 IM 中枢
+- **🧪 测试容器(用户裁决 2026-11-10,强制统一)**:本项目**所有涉及容器交互的测试一律使用 `agent-node-lj-test`**(Matrix `@zgdcmgnqcd:fmode.cn`)。
+  - 不得使用 `agent-node-app-lj-test-v1`(`@axmojmfphr:fmode.cn`)—— 它是**另一个容器**,成果表/文件目录互不相通。
+  - 历史踩坑:2026-11-10 做成果上云验证时误用 v1,导致「容器说上传成功、App 成果端为空」的假象,排查耗时。
+  - 新容器接入、成果上云、会话收发、群聊协作等一切实测,以该容器为唯一被测对象。
 - 应用类型与规模:**单前端 Angular SPA**(无自建后端);双端路由 `/desktop/**` + `/mobile/**`,约 20 条路由;不做响应式布局切换——入口页 `replaceUrl` 按 UA/视口一次性判定跳端,之后各端布局固定;两端共享 `core/` 服务与 `components/` 组件
 - **fmode-ng 依赖版本与初始化 config 实例名**:`fmode-ng ^0.0.246`,`FmodeParse.with('nova')` → appId `ncloudmaster` / `serverURL https://server.fmode.cn/parse`
 - 目录布局:**单 Angular workspace(非 monorepo)**,根目录即应用;`src/app/` 下计划 `core/`(共享服务)· `components/`(业务外壳组件)· `ui/`(ui-kit 官方底座,整目录接入,禁改)· `desktop/` · `mobile/` · `pages/`

+ 353 - 0
cloud/functions/fmode-file-hub.js

@@ -0,0 +1,353 @@
+// fmode-file-hub v0.1.0 — 文件台账(按会话隔离)(表: AgentDeliverable)
+//
+// ## 为什么新建而不是改老函数
+//
+// 老函数 lfYlgU7SkK(fmodeagent-deliverables) 的 report 用 UPDATABLE 白名单写字段,
+// 里面**没有 threadRoot** —— 容器传了也会被静默丢弃,所以成果永远无法归属到会话。
+// 用户裁决(2026-11-10):**现有云函数一行不动,新建独立函数走新接口**。
+//
+// ## 职责边界(用户裁决)
+//
+//   · 消息**不入库**(单聊/群聊都在 Matrix)
+//   · 只有**文件与成果**入库,目的是让右侧栏能按会话直接加载
+//   · 只存访问链接与文件元信息,**不存文件内容**(内容由 AlP56LCKFm 签名直传 OBS)
+//   · 不做老数据适配:没有 threadRoot 的历史成果不出现,按用户裁决"全面切新逻辑"
+//
+// ## 调用
+//
+//   POST /api/functions {token, id:<Function objectId>, params:{action, ...}}
+//   action: registerFile | listBySession | listByAgent
+//
+// ## 表
+//
+//   复用 AgentDeliverable(已有 user / company / ACL),新增字段:
+//   threadRoot / uploadedBy / fileName / fileSize / mimeType / storageKey / roomId
+
+// ==================== 基础工具(各云函数本就各自复制一份,沿用惯例) ====================
+
+var CLASS_NAME = 'AgentDeliverable';
+var MAX_LIMIT = 200;
+var DEFAULT_LIMIT = 50;
+
+/** 会话标识、URL、文件名等的长度上限 */
+var LEN = { threadRoot: 200, roomId: 200, agentId: 120, url: 1000, name: 200, key: 500, mime: 120 };
+/** 单个文件大小上限(与 App 侧 upload-limits.ts 的 20MB 保持一致) */
+var MAX_FILE_BYTES = 20 * 1024 * 1024;
+
+function _ok(response, data) {
+  var body = { code: 200 };
+  if (data) for (var k in data) body[k] = data[k];
+  response.status(200).json(body);
+}
+
+function _fail(response, code, msg) {
+  response.status(200).json({ code: code, msg: msg });
+}
+
+function _str(v, max) {
+  if (v === undefined || v === null) return '';
+  var s = String(v).trim();
+  return max && s.length > max ? s.slice(0, max) : s;
+}
+
+function _num(v) {
+  var n = Number(v);
+  return isFinite(n) && n >= 0 ? n : null;
+}
+
+function _sessionToken() {
+  // 与 fmodeagent-status 同口径:请求头优先(平台实测只保留请求头)
+  if (request.headers && request.headers['x-parse-session-token']) {
+    return request.headers['x-parse-session-token'];
+  }
+  if (params && params.token) return params.token;
+  if (request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    q.include('user');
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) {
+    return null;
+  }
+}
+
+/** 云盘链接只接受本平台域名,防止登记任意外链冒充成果 */
+function _isCloudUrl(u) {
+  return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
+}
+
+/**
+ * 确保 class 上存在某个列(2026-11-10)。
+ *
+ * 🔴 Parse 的自动建列**只在写入时发生**:数据库里还没有 `threadRoot` 列时,
+ *    直接 `equalTo('threadRoot', x)` 会让 PostgreSQL 报 42703 errorMissingColumn。
+ *    历史数据本来就没有这个字段,所以第一次查询必然踩到。
+ *
+ * 做法:写一个带该字段的探测对象再立刻销毁,把列建出来。
+ * 只在捕获到缺列错误时执行一次,正常路径零开销。
+ */
+var _ensured = false;
+/**
+ * 一次性建出所有新列。
+ *
+ * 历史数据里这些字段一个都不存在,分散建列会让每个字段都触发一次
+ * 失败查询 + 重试,这里一次建全,后续查询零开销。
+ */
+async function _ensureColumns() {
+  if (_ensured) return;
+  var probe = new Parse.Object(CLASS_NAME);
+  probe.set('threadRoot', '');
+  probe.set('fileUrl', '');
+  probe.set('uploadedBy', '');
+  probe.set('fileName', '');
+  probe.set('fileSize', 0);
+  probe.set('mimeType', '');
+  probe.set('storageKey', '');
+  probe.set('roomId', '');
+  await probe.save(null, { useMasterKey: true });
+  // 探测对象必须销毁,否则会在成果列表里留下一条空记录
+  await probe.destroy({ useMasterKey: true });
+  _ensured = true;
+}
+
+/**
+ * 判断是否「列不存在」错误。
+ *
+ * 🔴 只认 PostgreSQL 的错误码,不能按字段名匹配 ——
+ *    查询语句本身就包含字段名,按名匹配会让任何错误都被当成缺列。
+ */
+function _isMissingColumn(e) {
+  try {
+    var s = JSON.stringify(e);
+    return s.indexOf('42703') >= 0 || s.indexOf('errorMissingColumn') >= 0;
+  } catch (e2) {
+    return false;
+  }
+}
+
+// ==================== 行 → 返回对象 ====================
+
+function _toFile(r) {
+  var artifacts = r.get('artifacts') || [];
+  var first = Array.isArray(artifacts) && artifacts.length ? artifacts[0] : null;
+  if (typeof first === 'string') first = { url: first, name: '' };
+
+  return {
+    id: r.id,
+    threadRoot: r.get('threadRoot') || '',
+    agentId: r.get('agentId') || '',
+    roomId: r.get('roomId') || '',
+    // 文件信息优先取新字段,回退到 artifacts[0](两者写入时保持一致)
+    name: r.get('fileName') || (first && first.name) || '',
+    size: typeof r.get('fileSize') === 'number' ? r.get('fileSize') : -1,
+    mimeType: r.get('mimeType') || (first && first.mimeType) || '',
+    url: (first && first.url) || '',
+    storageKey: r.get('storageKey') || '',
+    uploadedBy: r.get('uploadedBy') || 'agent',
+    status: r.get('status') || 'delivered',
+    deliveredAt: (r.get('deliveredAt') || r.get('createdAt'))
+      ? new Date(r.get('deliveredAt') || r.get('createdAt')).getTime()
+      : 0,
+  };
+}
+
+// ==================== actions ====================
+
+/**
+ * registerFile —— 登记一条文件记录
+ *
+ * 用户上传(uploadedBy:'user')与容器产出(uploadedBy:'agent')共用。
+ * 幂等:同一 (user, threadRoot, url) 重复登记返回已有记录,不产生重复。
+ */
+async function opRegisterFile(user, response) {
+  var p = params || {};
+  var threadRoot = _str(p.threadRoot, LEN.threadRoot);
+  var url = _str(p.url, LEN.url);
+  var name = _str(p.name, LEN.name);
+
+  if (!threadRoot) return _fail(response, 400, 'threadRoot required');
+  if (!url) return _fail(response, 400, 'url required');
+  if (!_isCloudUrl(url)) return _fail(response, 400, 'url must be a platform cloud link');
+  if (!name) return _fail(response, 400, 'name required');
+
+  var size = _num(p.size);
+  if (size !== null && size > MAX_FILE_BYTES) return _fail(response, 400, 'file too large');
+
+  var uploadedBy = p.uploadedBy === 'user' ? 'user' : 'agent';
+  var agentId = _str(p.agentId, LEN.agentId);
+
+  /*
+   * 幂等:同一会话内同一 URL 只存一条。
+   *
+   * 🔴 不能查 `artifacts.url`(数组内字段)—— 实测该查询永远匹配不到,
+   *    会导致重复登记。改查顶层 `fileUrl`:registerFile 写入时同步落一份。
+   *    该列首次查询时也不存在,需要建列后重试(见 _ensureColumn)。
+   */
+  var dupQ = new Parse.Query(CLASS_NAME);
+  dupQ.equalTo('user', user);
+  dupQ.equalTo('threadRoot', threadRoot);
+  dupQ.equalTo('fileUrl', url);
+  var dup;
+  try {
+    dup = await dupQ.first({ useMasterKey: true });
+  } catch (e) {
+    if (!_isMissingColumn(e)) throw e;
+    await _ensureColumns();
+    dup = await dupQ.first({ useMasterKey: true });
+  }
+  if (dup) return _ok(response, { id: dup.id, duplicated: true });
+
+  var obj = new Parse.Object(CLASS_NAME);
+  obj.set('user', user);
+  obj.set('threadRoot', threadRoot);
+  obj.set('roomId', _str(p.roomId, LEN.roomId));
+  obj.set('agentId', agentId);
+  obj.set('uploadedBy', uploadedBy);
+  obj.set('fileName', name);
+  obj.set('fileSize', size === null ? -1 : size);
+  obj.set('mimeType', _str(p.mimeType, LEN.mime));
+  obj.set('storageKey', _str(p.key, LEN.key));
+  /* 幂等键:顶层单独存一份 URL,见 opRegisterFile 的去重说明 */
+  obj.set('fileUrl', url);
+  obj.set('title', name);
+  obj.set('summary', p.source ? '容器产出' : '用户上传');
+  obj.set('status', 'delivered');
+  obj.set('deliveredAt', new Date());
+  obj.set('reportedAt', new Date());
+  obj.set('tags', ['file-hub', uploadedBy]);
+  /*
+   * artifacts 保留数组形态:App 现有的 Deliverable 映射(mapDeliverable)
+   * 按 artifacts[0].url 取链接,写在这里就不用改 App 的解析层。
+   */
+  obj.set('artifacts', [
+    {
+      name: name,
+      url: url,
+      type: _str(p.mimeType, LEN.mime),
+      source: _str(p.source, LEN.url),
+    },
+  ]);
+
+  // ACL:只给本人读写(成果属于个人会话,不跨用户可见)
+  var acl = new Parse.ACL(user);
+  acl.setPublicReadAccess(false);
+  acl.setPublicWriteAccess(false);
+  obj.setACL(acl);
+
+  // 公司指针从 agent 带过来,保持与老成果表一致的多租户口径
+  if (agentId) {
+    try {
+      var aq = new Parse.Query('FmodeAgent');
+      aq.equalTo('agentId', agentId);
+      var agent = await aq.first({ useMasterKey: true });
+      if (agent) {
+        obj.set('agent', agent);
+        obj.set('agentObjectId', agent.id);
+        var company = agent.get('company');
+        if (company) obj.set('company', company);
+        if (!agentId) obj.set('agentId', agent.get('agentId') || '');
+      }
+    } catch (e) {
+      /* agent 查不到不影响登记:文件仍归属到用户与会话 */
+    }
+  }
+
+  var saved = await obj.save(null, { useMasterKey: true });
+  return _ok(response, { id: saved.id });
+}
+
+/**
+ * listBySession —— 按会话拉文件(右侧栏主路径)
+ *
+ * 这是用户要求「每次直接从数据库加载文件」的入口:
+ * 不依赖加载聊天历史,也不依赖 FmodeChatThread.deliverableUrls。
+ */
+async function opListBySession(user, response) {
+  var p = params || {};
+  var threadRoot = _str(p.threadRoot, LEN.threadRoot);
+  if (!threadRoot) return _fail(response, 400, 'threadRoot required');
+
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', user);
+  q.equalTo('threadRoot', threadRoot);
+  if (p.before) {
+    var before = new Date(Number(p.before));
+    if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
+  }
+  q.descending('deliveredAt');
+  q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
+
+  var rows;
+  try {
+    rows = await q.find({ useMasterKey: true });
+  } catch (e) {
+    // 列还不存在 → 建列后重试一次(历史数据没有 threadRoot,首次查询必然走到这里)
+    if (!_isMissingColumn(e)) throw e;
+    await _ensureColumns();
+    rows = await q.find({ useMasterKey: true });
+  }
+  return _ok(response, {
+    threadRoot: threadRoot,
+    files: rows.map(_toFile),
+    count: rows.length,
+  });
+}
+
+/** listByAgent —— 按容器拉(成果页用,不按会话过滤) */
+async function opListByAgent(user, response) {
+  var p = params || {};
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', user);
+  var agentId = _str(p.agentId, LEN.agentId);
+  if (agentId) q.equalTo('agentId', agentId);
+  if (p.before) {
+    var before = new Date(Number(p.before));
+    if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
+  }
+  q.descending('deliveredAt');
+  q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
+
+  var rows = await q.find({ useMasterKey: true });
+  return _ok(response, { files: rows.map(_toFile), count: rows.length });
+}
+
+// ==================== 入口 ====================
+
+function handler(request, response) {
+  (async function () {
+    var p = params || {};
+    var act = p.action || 'listBySession';
+
+    var token = _sessionToken();
+    var user = await _getUser(token);
+    if (!user) return _fail(response, 401, 'invalid session');
+
+    try {
+      if (act === 'registerFile') return await opRegisterFile(user, response);
+      if (act === 'listBySession') return await opListBySession(user, response);
+      if (act === 'listByAgent') return await opListByAgent(user, response);
+      return _fail(response, 400, 'unknown action: ' + act);
+    } catch (e) {
+      /*
+       * Parse 的异常对象字符串化是 `[object Object]`,直接丢出去排查不了。
+       * 新函数上线初期必须能看到真实原因(字段不存在 / 索引缺失等)。
+       */
+      var msg = 'unknown';
+      try {
+        msg = (e && (e.message || e.error || e.code)) || String(e);
+        if (typeof msg === 'object') msg = JSON.stringify(msg);
+      } catch (e2) {
+        msg = String(e);
+      }
+      return _fail(response, 500, String(msg));
+    }
+  })();
+}

+ 236 - 0
docs/00-tracking/2026-11-10-交接-未完成事项清单.md

@@ -0,0 +1,236 @@
+# 交接文档:未完成事项清单(2026-11-10)
+
+| 字段 | 值 |
+|---|---|
+| 用途 | **新会话接手时先读这份**,了解做到哪了、还剩什么 |
+| 测试容器 | `agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`)— 用户裁决,**所有容器测试只用这一个** |
+| 当前分支 | `master`,所有改动**未提交**(等用户确认) |
+| 基线状态 | `npm test` 1459 条全过 · `tsc` ✅ · `npm run build` ✅ |
+
+---
+
+## 一、本轮已完成(不用再做)
+
+| 项 | 说明 | 验证 |
+|---|---|---|
+| 计时稳定递增 | 从用户发消息起算,不再卡住跳变 | 单测 + 真机 38s |
+| 容器日志不再刷屏 | 工具行占比过半才折叠 | 单测 |
+| 「已完成」口径 | 无交付时显示「已结束 · 无交付」 | 单测 |
+| 超时改为 3 分钟 | `TURN_TIMEOUT_MS = 180_000` | 单测 |
+| 超时提示必弹 | 看门狗独立于会停的时钟 | 单测 |
+| 提示每条消息只弹一次 | 超时即终态,刷新不再弹 | **真机验证通过** |
+| 移动端提示独立样式 | 整宽、按钮 48px、不竖排 | 真机验证 |
+| 结果 emoji 合并 | `✅/❌` 连续行合并成一段,不再一卡一行 | 单测 |
+| PC/Mobile 自动切换 | 按宽度自动跳转,去掉手动按钮 | **真机验证通过** |
+| 成果文件名与存储键分离 | `prettifyStorageKey` | 单测 |
+
+---
+
+## 二、未完成的环节(新会话要做的)
+
+### P0 · 阻断体验
+
+#### 1. 上云链路只在「临时房间」验证通过,**真实会话仍有缺陷**
+
+**已确认的事实:**
+
+- 我用脚本建临时房间测试,连续 3 轮 **6/6 全绿**(上传、成果登记、正文带链接、无本地路径)
+- 但在 **App 真实会话**里发需求(`app-e2e-1794288808946.md`):
+  - ✅ 文件**确实上云了**(成果表有记录)
+  - ❌ 回复正文**没有 https**
+  - ❌ 正文残留「本地路径:(本地工作目录)」
+
+**待查:**
+
+- 临时房间 vs 真实会话(thread)在 hook/插件层面的差异
+- 容器自述「插件需重启才生效」,但那之后真实会话仍未验证通过
+
+**涉及文件:** 容器侧 `fmode-deliverable-hook.py`、`plugin-deliverable-inject`
+
+#### 2. 容器端「默认上云」尚未在真实场景证明
+
+**用户核心需求**:用户**不提**「上传」二字,产出文件也要自动上云。
+
+**当前状态:**
+
+- 机制已建(`config.yaml` hooks + Python 插件)
+- 临时房间通过,真实会话未通过
+- 容器曾自编假链接(`https://s3.fmode.cn/user/xxx/report/xxx.md` → 404),已修三层防护,但需重新验证
+
+#### 3. 云函数无删除能力,测试数据清不掉
+
+**问题:**
+
+- 云函数 `lfYlgU7SkK` **没有 `remove` action**
+- 成果表里堆积了测试数据:`meeting-*`、`app-e2e-*`、`retro-*`、`SKILL.md`、`probe-*`
+- 之前建的临时 Matrix 房间也在
+
+**需要:** 云函数加删除能力,或提供控制台清理方式
+
+---
+
+### P1 · 应该做
+
+#### 4. 用户上传文件未改成「先上云」
+
+**用户要求**:「我上传文件之后,默认上传到我的云盘里面,然后同步到数据库」
+
+**当前实现**(`chat.service.ts` `uploadPending`):
+```
+先发 Matrix 附件(mxc://)→ 再额外传 S3
+```
+
+**容器给出的硬约束**(读源码确认):
+```python
+# adapter.py:2136
+if url and not str(url).startswith("mxc://"):
+    return   # https 放进媒体事件会被直接丢弃
+```
+
+**结论:**
+
+| 文件大小 | 通道 |
+|---|---|
+| ≤20MB | 必须走 `mxc://`(容器只认这个) |
+| 大文件 | 先上云 → **纯文本 body** 放 https |
+
+**待做:** App 侧按大小分流,大文件走「云端 + 正文链接」
+
+#### 5. 本地文件不累积,但做不到「零本地写入」
+
+**用户诉求**:本地空间有限,文件主要在云端。
+
+**容器侧已做**:PUT 成功后 `md5` 校验通过即删除本地源文件。
+
+**限制**:服务端契约要求 `report`/`enqueueArtifact` 收**容器本地路径**,drain 要 `open(路径)` 才能 PUT —— 秒级中转不可避免。
+
+**若要真正零写入**:需改云函数加 base64/流直传 action(平台侧改动,未排期)
+
+---
+
+### P2 · 技术债
+
+#### 6. `transform_llm_output` hook 仍是坏的
+
+容器读源码确认两个断点:
+- `shell_hooks._payload_fields()` 把正文塞进 `extra`,但 hook 读顶层
+- hook 输出 `{response_text}`,但 Hermes 只认 `{context}`
+
+**当前绕法**:改用 Python 插件 `plugin-deliverable-inject`(回调返回 str 才被采纳)
+
+**风险**:若 Hermes 升级改动契约,插件会失效,需重新验证
+
+#### 7. 交付协议 v2(`<!--fmode:files-->`)从未被容器实现
+
+- App 侧已实现(`core/declared-files.ts`)
+- 容器明确回答「不知道这个协议,从没写过」
+- **实际交付走的是 `m.file` + 正文链接**
+
+**建议**:要么让容器实现,要么在 App 侧废弃该分支
+
+#### 8. `messageCount` 双写虚高
+
+左栏消息数偏大,未修。属数据模型变更。
+
+---
+
+## 三、需要从容器获得的信息
+
+### 已问到(有答案)
+
+| 问题 | 容器回答 |
+|---|---|
+| 回复分几条发? | 分条发,回执先发、结果后发、长任务插播进度 |
+| 挂 thread 吗? | **不挂**,只发普通消息 |
+| `tool_progress` / `interim` | `all` / `true` |
+| 上传链路 | `report` → `enqueueArtifact` → `drain` → md5 回读 |
+| `report` 会自动入队吗? | **不会**,必须显式 `enqueueArtifact` |
+| 用哪个云函数 | `lfYlgU7SkK`(**不是** `xlosi3m46q`,那个对任何 action 都返回 200) |
+
+### 还需要问
+
+1. **真实会话 vs 临时房间的差异**:为什么临时房间能带链接、真实会话不能?
+2. **插件重启后是否真的加载**:需要进程启动时间 vs 插件 mtime 的证据
+3. **产出文件的判定规则原文**:当前 `SYSTEM_PREFIX_DENY` 列表能否覆盖所有系统文件
+4. **大文件读取能力**:https 纯文本链接的最大可靠大小
+
+---
+
+## 四、已知的关键流程(新会话必读)
+
+### 4.1 容器上云链路
+
+```
+① 容器产出文件 → /opt/data/work/xxx
+② post_tool_call hook → 写入队列状态文件(无网络 IO)
+③ transform_llm_output hook → report + enqueueArtifact + drain + md5 回读
+   ⚠️ 这一步的文本输出会被 Hermes 丢弃(hook 接口不兼容)
+④ plugin-deliverable-inject(Python 插件)→ 把 https 追加进回复正文
+   ✅ 只有 Python 插件回调返回 str 才被采纳
+⑤ 上传成功后删除本地源文件
+```
+
+**验证命令:**
+```bash
+npm run verify:upload              # 5-6 项判定,可指定 --agent / --prompt
+```
+
+### 4.2 Matrix 消息归属(App 侧)
+
+`chat.service.ts` `belongsToSession()`:
+
+| 情形 | 处理 |
+|---|---|
+| 有 thread 且 threadId === 会话 | 认领 |
+| 无 thread + dispatch 标记 | 丢弃(群聊派活) |
+| 无 thread + sessionId 标记 | 按 sessionId 比对 |
+| **无 thread + 实时 + 本会话有待回复消息** | **认领**(2026-11-10 新增,修「完全没反应」) |
+| 无 thread + 历史回填 | 丢弃 |
+
+### 4.3 超时逻辑
+
+```
+用户发消息 → 看门狗启动(每 5 秒)
+  ↓ 180 秒无回复
+判定超时 → 记入 _handledTimeouts → 弹全局提示 → 不再判定
+  ↓ 容器回复 / 用户发新消息
+下一轮重新开始
+```
+
+**关键**:看门狗存活窗口 = `TURN_TIMEOUT_MS + 10s`,刷新后老消息超出窗口 → 不启动 → 不弹。
+
+---
+
+## 五、当前工作区状态
+
+**未提交的改动**(`git status`):
+
+- `CLAUDE.md` — 加测试容器约定
+- `package.json` — 加 `verify:upload` 脚本
+- `src/app/` 下 21 个文件(会话、超时、设备切换、成果、合并等)
+- 新增文档 2 份 + 验收脚本
+
+**新增的脚本**(`scripts/`):
+- `verify-upload.mjs` — 上云验收(可复用,任意容器)
+- `ask-ljtest.mjs` — 批量短问题问容器(长问题容器处理不了)
+
+> ⚠️ 大量 `debug-*.mjs`、`probe-*.mjs` 是临时调试脚本,交接前应清理。
+
+---
+
+## 六、新会话建议的起步动作
+
+1. **先跑基线**:`npm test` + `npx tsc -p tsconfig.app.json --noEmit`
+2. **确认容器在线**:`node scripts/matrix-check.mjs agent-node-lj-test`
+3. **复现 P0-1**:在 App 真实会话里发一条不带上传字眼的需求,看回复是否带 https
+4. **按本文档 §四 的流程定位断点**
+
+---
+
+## 七、关联文档
+
+| 文档 | 用途 |
+|---|---|
+| `docs/02-design/plans/2026-11-10-文件上云完整方案.md` | 上云方案完整设计 |
+| `docs/00-tracking/2026-11-10-修改留痕-会话区三缺陷与上云链路.md` | 本轮修改留痕 |
+| `CLAUDE.md` | 项目规则(含测试容器约定) |

+ 184 - 0
docs/00-tracking/2026-11-10-修改留痕-会话区三缺陷与上云链路.md

@@ -0,0 +1,184 @@
+# 修改留痕:会话区三缺陷修复 + 成果上云链路打通
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-10 |
+| 触发 | 用户实测桌面会话区:①计时卡住再跳变 ②文件卡片不出现 ③容器日志被当正文 |
+| 测试容器 | `agent-node-lj-test`(用户裁决,已写入 CLAUDE.md) |
+| 结果 | 前端三项已修复并真机验证;上云链路根因定位在容器侧,已修复并端到端验证 |
+
+---
+
+## 一、三个前端缺陷
+
+### A · 计时卡住再跳变
+
+**根因**:`seconds` 是「末条 agent 消息 - 首条 agent 消息」的**事件时间差**,
+每秒 tick 不改变时间戳,所以数字不动;新消息到达才跳一大截。
+
+**修法**(`core/assistant-turn.ts` `toBlockView`):
+- 锚点改为**用户发出消息的时间**(`waitingSince`)
+- `live` 时用**当前时钟**(稳定递增),结束后冻结
+- 群聊 `roundSeconds` 同步改口径(原先仍是旧算法,会造成同一张卡两个数)
+
+**效果**:实测显示 `38s`,稳定递增;容器排队时间也计入用户等待。
+
+### B · 容器内部日志刷屏正文
+
+**根因**:`isToolActivityMessage` 要求「**每一行**都是工具行」才整条折叠。
+容器实测输出是**混排**(工具行 + 一句中文叙述),只要夹一句中文就整条进正文。
+
+**修法**(`core/message-segments.ts`):改为**工具行占比过半**即判为进度消息。
+- `2 工具 + 1 中文` → 67%,折叠
+- `1 工具 + 1 中文` → 50%,留正文(宁可漏折不可误折)
+
+**「已完成」口径**(`core/assistant-turn.ts` `phaseOf`):
+原判据「只要不是全空就算 done」→ 容器只回一串进度也标「已完成」。
+新增 `progressOnly` 阶段,只有过程无正文/文件时显示「已结束 · 无交付」。
+
+### C · 文件卡片不出现
+
+三级优先级(`m.file` → `fmode:files` 声明段 → 正文 S3 链接)**全部落空**。
+
+**修法**:新增第 ④ 级——识别正文里的 `/opt/data/...` 容器本地路径
+(`core/s3-link.ts` `extractContainerPaths`),**不建可点卡片**(路径打不开),
+在回合卡底部给一条**不可点击**的提示:「容器产出了 N 个文件,尚未上传云盘」。
+
+> ⚠️ 这条只是**如实告知**,真正的修复见 §二。
+
+---
+
+## 二、成果上云链路:根因在容器侧(本次最重要)
+
+### 用户判断是对的
+
+用户指出「容器应该会自动上传到云盘,成果端才加载出来」。
+我最初想从前端补(按时间窗从成果表归集),但那只是**兜底**,不是修复。
+
+### 实测定位
+
+链路是**两步**,缺一不可:
+
+```
+① action=report        建记录,返回 id 作 deliverableId
+② action=enqueueArtifact 入队(带 deliverableId/agentId/source/filename/index)
+③ cron drain(每 3 分钟)取队列 → PUT OBS → ack → 回写 url
+```
+
+**实测证据**:调 `report` 后立刻查 `pendingArtifacts` → `jobs: []`
+→ **`report` 不会自动入队**,必须显式再调 `enqueueArtifact`。
+
+**容器自述**:
+> 「这次我只用了 write_file 写文件、在正文里写了路径,**从来没调 deliverable-report**。
+> 这是流程漏步,不是技术故障。」
+> 「50 次运行,**50 次全部是'队列为空'**,没有一次成功搬运。」
+
+### 容器侧还有第三个坑(比两步更致命)
+
+容器 `.env` 里的 `AGENT_FUNCTION_ID=xlosi3m46q` 是**状态查询函数**,
+它对任何 action(包括拼错的 `__nope__`)都**静默返回 `{"code":200}`**。
+用它调 `report`,看起来成功、实际什么都没做。
+
+成果链路真正的函数是 **`lfYlgU7SkK`**。
+
+### 容器侧已修复
+
+- 新增 `/opt/data/scripts/fmode-artifact-push.py`:一次完成 report → enqueue → drain → **curl 回读比对 md5**
+- 成功判据是 `RESULT OK`(含 md5 identical),失败非零退出
+- 写进 skill:产出文件后必须跑该脚本,未拿到 https 不得声称已交付
+
+**实测**:
+```
+[size=1776][http=200][ct=text/markdown]
+42efcb60edf379d0da88bb9ce4f59f37  /tmp/dl.md
+42efcb60edf379d0da88bb9ce4f59f37  /opt/data/work/weekly-report-template.md
+identical: YES
+```
+
+---
+
+## 三、前端兜底:**已按用户裁决撤销**
+
+### 曾经加过(后被撤销)
+
+为让成果端能看到,曾新增 `DeliverablesService.byAgentSince(agentId, since)`,
+按「容器 + 时间窗」从成果表自动归集,四端接入;
+并曾加过「容器产出了 N 个文件,尚未上传云盘」的提示。
+
+### 为什么撤销(用户 2026-11-10 裁决)
+
+用户明确了**唯一正确的链路**:
+
+```
+① 容器产出成果文件
+② 上云,拿到在线链接
+③ 把链接连同执行数据返回前端
+④ 前端解析链接 → 进入成果栏
+```
+
+用户原话:
+
+> 「不要显示这个文件是否上云等之类一些内容。要确保在回复之前,就已经能够上云。
+> 那如果没能够上云的话,那也不要去显示对应的成果。」
+
+即:**单链路、无兜底**。没上云就没有成果,不在前端补数据、也不提示「未上云」。
+
+### 撤销清单
+
+| 项 | 处置 |
+|---|---|
+| `DeliverablesService.byAgentSince()` | 删除 |
+| 四端 `byAgentSince` 接入 | 删除(恢复纯消息链路) |
+| `Message.pendingUploads` 字段 | 删除 |
+| `TurnBlockView.pendingUploads` | 删除 |
+| `extractContainerPaths()`(s3-link.ts) | 删除 |
+| 气泡/回合卡的「未上云」提示 | 删除 |
+| 对应测试 | 删除 |
+
+保留 `DeliverablesService.merge()`(按 URL 去重),它是 `bySessionUrls` + `fromAgentFiles`
+两个既有来源合并时需要的,不属于兜底。
+
+### 当前前端职责
+
+前端回到**纯解析者**角色:只认消息里的 `m.file` / `fmode:files` 声明段 / 正文 S3 链接。
+容器不上云 = 前端没有可得的数据 = 成果栏为空。**这是正确的失败表现。**
+
+---
+
+## 四、测试容器统一(用户裁决)
+
+**所有涉及容器的测试一律使用 `agent-node-lj-test`**(`@zgdcmgnqcd:fmode.cn`),
+不得用 `agent-node-app-lj-test-v1`(`@axmojmfphr:fmode.cn`)—— 两者是**不同容器**,成果表互不相通。
+
+已写入 `CLAUDE.md` 项目补充信息。
+
+**踩坑**:本次先误用 v1 验证,出现「容器说上传成功、App 成果端为空」的假象,
+是因为会话绑定的 agentId 是 `agent-node-lj-test`,而成果写在 v1 名下。
+
+---
+
+## 五、验证汇总
+
+| 项 | 结果 |
+|---|---|
+| `tsc`(app + spec) | ✅ |
+| `npm test` | ✅ **1446 SUCCESS**(原 1423,+23) |
+| `npm run build` | ✅ |
+| `verify:ui` | 11/12(AC-12.6 ui-kit 覆盖率为存量门槛问题) |
+| 计时稳定递增 | ✅ 真机 38s |
+| 日志不再刷屏 | ✅ 真机确认 |
+| 未上云提示 | ✅ 真机显示「容器产出了 1 个文件,尚未上传云盘」 |
+| 成果端加载 | ✅ 真机 0 → 1 |
+
+---
+
+## 六、遗留与未验证项(如实披露)
+
+| # | 项 | 状态 |
+|---|---|---|
+| 1 | 探测记录 `kgYyfPrFxb`(v1 名下)删不掉 | 云函数无 `remove` 动作,需控制台手动删 |
+| 2 | v1 容器名下 4 条探测记录 | 同上 |
+| 3 | 容器 `fmode-artifact-push.py` 是容器侧自建 | 未提交到项目仓库,仅容器内存在 |
+| 4 | 群聊多容器成果归集 | 逻辑已统一,未做多容器实测(当前仅 2 个可用容器) |
+| 5 | `report` 加 `threadRoot` 精确关联 | 未做,需云函数字段扩展 |
+| 6 | AC-12.6 ui-kit 覆盖率 22/30 | 存量门槛问题,非本次引入 |

+ 188 - 0
docs/00-tracking/2026-11-10-修改留痕-文件台账与按会话隔离.md

@@ -0,0 +1,188 @@
+# 修改留痕:文件台账(按会话隔离)+ 上云链路加固
+
+> 日期:2026-11-10
+> 关联文档:`docs/03-interface/容器任务书-成果按会话归属-20261110.md`
+> 关联取证:`docs/03-interface/容器取证-上云与会话逻辑-20261110.md`、`docs/03-interface/容器追问-url为空与会话归属-20261110.md`
+
+## 一、做了什么
+
+### 1. 新建云函数 `fmode-file-hub`(`THXAOGKiPK`)
+
+**为什么新建而不改老函数**:老成果函数 `lfYlgU7SkK` 的 `report` 用 `UPDATABLE` 白名单(源码 L235)写字段,**里面没有 `threadRoot`**,容器传了也被静默丢弃,成果永远无法归属到会话。用户裁决:**现有云函数一行不动,新建独立函数**。
+
+| action | 作用 |
+|---|---|
+| `registerFile` | 登记文件(用户上传 / 容器产出共用),幂等 |
+| `listBySession` | 按会话拉文件(右侧栏主路径) |
+| `listByAgent` | 按容器拉(成果页) |
+
+- 复用表 `AgentDeliverable`,新增字段:`threadRoot` / `fileUrl` / `uploadedBy` / `fileName` / `fileSize` / `mimeType` / `storageKey` / `roomId`
+- 鉴权:token 查 `_Session`,`user` 由服务端确定,不接受客户端传入
+- URL 白名单:只接受 `s3.fmode.cn` / `file-cloud.fmode.cn`,防止登记外链冒充成果
+- 源码:`cloud/functions/fmode-file-hub.js`
+
+### 2. 踩坑:Parse 列不存在
+
+🔴 **Parse 的自动建列只在写入时发生**。历史数据里没有 `threadRoot`,直接 `equalTo('threadRoot', x)` 会让 PostgreSQL 报 `42703 errorMissingColumn`。
+
+处理:捕获该错误 → 写一条探测对象把列建出来 → 销毁探测对象 → 重试查询(`_ensureColumns()`)。
+
+🔴 **`_isMissingColumn` 不能按字段名匹配** —— 查询语句本身就包含字段名,按名匹配会把任何错误都当成缺列。只认 `42703` / `errorMissingColumn`。
+
+### 3. 踩坑:幂等查询不能用数组内字段
+
+`equalTo('artifacts.url', url)` 永远匹配不到(`artifacts` 是数组),导致重复登记产生两条记录。改为顶层单独存 `fileUrl` 再查。
+
+### 4. 用户上传:大小限制 + 先上云再发送
+
+- 新增 `src/app/core/upload-limits.ts`:`MAX_UPLOAD_BYTES = 20MB`
+- `ChatService.stageFiles()`:选文件时拦截,超限项不进暂存区 + 提示
+- `ChatService.uploadPending()`:发送前兜底
+- `GroupSendService.stageFiles()`:群聊同规则
+
+**顺序调整**:原先是「先发 Matrix、再传 S3」,S3 失败只记一行 `_error`,结果是「消息发出去了、云盘没存上」。改为**先 S3、再 Matrix** —— 云盘存不上就不发送,失败项留在待发区重试。
+
+### 5. 群聊此前完全不上云
+
+`group-send.service.ts` 只发 Matrix,**永不进云盘、不记成果、无提示**。用户在大群发的文件换设备就没了。现已补 S3 双写 + 台账登记。
+
+### 6. 群聊成果串台(bug 修复)
+
+`mobile/pages/group-chat` 与 `desktop/pages/group-chat` 的成果栏按**成员容器**取全部成果(`byAgent(id)`)——A 群会显示这些成员在别处产出的文件。改为按**群会话 id**(threadRoot)查台账。
+
+### 7. 右侧栏改为从数据库加载
+
+`DeliverablesService.loadForSession(threadRoot)` + 四个页面(桌面/移动 × 单聊/群聊)的 `effect` 触发重拉。
+
+数据源三路合并,按可靠性排序:
+1. 文件台账按 threadRoot 查出来的(权威)
+2. 消息流里容器带的文件(实时兜底)
+3. `FmodeChatThread.deliverableUrls`(旧数据兜底,新逻辑不再写入)
+
+### 8. 交付协议解析改用容器真实格式
+
+取证确认(容器 Q12 原话):**从不写 `<!--fmode:files-->`**,实际写的是:
+
+```
+**已上云(可直接下载)**
+- https://s3.fmode.cn/...
+```
+
+`core/declared-files.ts` 整文件删除,改在 `core/s3-link.ts` 加 `extractDeliveredLinks()` / `hasDeliveryBlock()`。
+
+### 9. 上云失败可观测
+
+`DeliverablesService.load()` 末尾的 `syncPending` 结果原本被 `void` 掉,失败只进 signal 没人读。现在有失败就弹全局提示(`AlertService`,需手动关闭)。
+
+## 二、验证证据
+
+| 项 | 结果 |
+|---|---|
+| `tsc -p tsconfig.app.json --noEmit` | ✅ 无错误 |
+| `npm test` | ✅ **1466 SUCCESS**(基线 1459,本轮 +7) |
+| `npm run build` | ✅ 通过 |
+| 云函数 `THXAOGKiPK` 鉴权 | ✅ 无 token → `{"code":401}` |
+| 云函数参数校验 | ✅ 缺 threadRoot → `{"code":400}` |
+| 云函数外链拒绝 | ✅ 非平台域名 → `{"code":400}` |
+| 云函数幂等 | ✅ 重复登记返回 `duplicated:true`,只有一条记录 |
+| **真实端到端**:签名 → PUT OBS(200) → 回读(200) → registerFile → listBySession | ✅ 全通 |
+| **会话隔离**:另一会话查同一用户 | ✅ `files: []`,查不到 |
+| 测试数据清理 | ✅ 探测记录与 e2e 记录均已删除 |
+
+## 三、未完成 / 待容器
+
+| # | 项 | 状态 |
+|---|---|---|
+| 1 | 容器改调 `registerFile` 并带 threadRoot | **任务书已出**,待容器执行(`docs/03-interface/容器任务书-成果按会话归属-20261110.md`) |
+| 2 | 容器 `adapter.py` 把 thread_id 写进 session 状态文件 | 同上(hook payload 里没有 Matrix 字段,必须由 adapter 落盘) |
+| 3 | 老成果数据没有 threadRoot | 按用户裁决**不处理**,不在会话右栏显示 |
+| 4 | 任务域(`fetchTasks`) | 仍为桩,不在本轮范围 |
+| 5 | 群聊消息持久化 | 按用户裁决**不入库**,继续 Matrix 重建 |
+
+## 四、已知限制
+
+- `threadRoot` 就是 App 的会话 id(Matrix thread 根)。**同一 room 的不同 thread 是不同会话**,这正是用户要的「每个会话独立」。
+- 容器拿不到 threadRoot 时**跳过登记**,而不是用假值凑 —— 宁可少一条也不产生脏数据。
+- 云盘对象键 `user/<userId>/...` 天然绑定用户(`AlP56LCKFm:148`),跨用户不可见。
+- `AlP56LCKFm` 本身**没有 size 校验**,20MB 限制目前只在 App 侧;服务端硬门禁需另改(不在本轮,因用户要求不动老函数)。
+
+## 五、真实环境验证(2026-11-10,本地 dev server + 真实账号 用户9836)
+
+不部署,直接在 `localhost:4200` 用真实账号 + 真实云函数验证。
+
+### `scripts/verify-file-hub.mjs` —— 12/12 通过
+
+| # | 检查项 | 结果 |
+|---|---|---|
+| ①②③ | 取签名 → 真实 PUT 到 OBS(200) → 回读内容一致 | ✅ 38 字节一致 |
+| ④⑤ | 登记到台账 / 重复登记幂等(`duplicated=true`,只有一条) | ✅ |
+| ⑥⑦ | **绕开云函数直查数据库**:确实有一条,`uploadedBy/fileName/mimeType` 完整 | ✅ |
+| ⑧⑨ | 会话隔离:B 会话云函数与数据库**双查均为 0 条** | ✅ |
+| ⑩ | A 会话能查回自己的文件 | ✅ |
+| ⑪ | **真实浏览器**右侧栏确实调用了 `listBySession`(3 次) | ✅ |
+| ⑫ | 页面无致命 JS 错误 | ✅ |
+
+### `scripts/verify-real-upload.mjs` —— 7/7 通过
+
+真实会话 `$approot_mw9h5oi6_m96imj7x:fmode.cn` 里通过**浏览器文件选择器**上传:
+
+| # | 检查项 | 结果 |
+|---|---|---|
+| ③ | 文件入库且 `threadRoot` 归属当前会话 | ✅ |
+| ④⑤ | 记录信息完整、云盘链接 HTTP 200 | ✅ |
+| ⑥ | **刷新页面后**右侧栏仍从数据库拿到该文件 | ✅ 证明不依赖聊天历史 |
+| ⑦ | 右侧栏界面上实际显示出来 | ✅ 「本会话文件 2」 |
+
+截图:`test-results/file-hub-20261110/`、`test-results/real-upload-20261110/`
+
+### 验证中发现并修复的问题
+
+**文件气泡重复显示**:`m.file` 事件的 `body` 字段按 Matrix 协议就是文件名,App 把它当正文又渲染了一遍 → 界面出现「卡片 + 同名纯文字」两条。
+
+修复:新增 `isAttachmentEcho(body, fileName)` 纯函数判定,命中则不输出正文;配 4 条单测(`chat.service.spec.ts`)。
+
+### 已知未解决的(容器侧)
+
+真实会话里容器回复正文仍含 `/opt/data/work/...` 本地路径 —— 属容器任务书范围,App 侧不伪造处理。
+
+## 六、容器侧接入完成(2026-11-10)
+
+### 定位过程(三轮取证,全部有命令输出为证)
+
+| 轮次 | 结论 |
+|---|---|
+| 1 | 容器不知道新函数存在:`grep -rI 'THXAOGKiPK' /opt/data` → **0** |
+| 2 | `HERMES_SESSION_THREAD_ID` 为空。容器一度判断是 `set_session_vars` 位置参数错位 |
+| 3 | **推翻该判断**:`_SESSION_VARS` 与 `values` 19 对 19 逐位对齐,无错位。真正原因是 **shell hook 子进程拿不到 ContextVar**(父进程根本没导出该变量) |
+
+### 可靠取法(容器实测)
+
+| 方案 | 覆盖率 |
+|---|---|
+| `HERMES_SESSION_ID` 查 `state.db` 的 `sessions.thread_id` | ✅ **105/105** |
+| 切 `HERMES_SESSION_KEY` | ❌ 段位浮动,且 event_id 含 `:` 会被切碎 |
+| ContextVar | ❌ 子进程不可见 |
+
+### 容器改动
+
+- `/opt/data/scripts/fmode-deliverable-hook.py` 新增 `_thread_root()`(第 332-358 行):按 `HERMES_SESSION_ID` 只读查 `state.db`
+- 登记调用切到 `THXAOGKiPK` 的 `registerFile`;取不到 threadRoot 时打 `SKIP register: no threadRoot` 并跳过(**不造假值**)
+- 老函数 `lfYlgU7SkK` 的 report / enqueueArtifact / pendingArtifacts / ackArtifact **原样并存**
+- hook 每次调用都 exec 脚本 → **改完即生效,无需重启 gateway**
+
+### 独立核验(不只信容器自报)
+
+```
+curl listBySession threadRoot=$approot_mw9h5oi6_m96imj7x:fmode.cn
+count = 2
+  4pA5vi4s4q  hub-verify.md                uploadedBy=agent  ← 容器产出
+  HmeAvoDKkv  m-composer-1794298339227.md  uploadedBy=user   ← 用户上传
+```
+
+**两条链路并存、同一会话归属正确**,这是「容器产出文件」与「用户上传文件」同时落在一个会话里的直接证据。
+
+### 仍然存在的限制(如实披露)
+
+当前容器的 DM 房间历史上 **0 条**有 thread 的 session(`thread_id` 全是 NULL)。因此「容器产出文件的真实回合 push stdout」尚未取得——需要在**带 thread 根的会话**里触发一次产出才能完整验证。
+
+容器已用同一 threadRoot 实跑登记并回读成功(`4pA5vi4s4q`),说明登记逻辑本身可用。

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

@@ -7329,3 +7329,42 @@ tr '\0' '\n' < /proc/$(pgrep -f 'hermes gateway run')/environ | grep REQUIRE_MEN
 | **验证** | `scripts/verify-switch-both-ways.mjs` 5 项全过:缩窄提示 → 切移动端 → **放大提示切回** → 切回桌面 → 不再提示。全量 `npm test` **1481 SUCCESS** |
 | **文档** | 新增 `docs/00-tracking/2026-11-10-完整修改记录.md`(当天全部改动总说明 + 拉取后如何验证);同步更新 `当前业务逻辑总览.md` 的端分流与组织准入章节 |
 | **提交** | 按用户要求提交并 push,保证其他机器拉取后功能一致 |
+
+---
+
+## 2026-11-10 · 文件台账(按会话隔离)+ 上云链路加固
+
+> ⚠️ 本条汇总补录:11-10 当天的详细记录此前只作为独立文件存在,未写入本追踪日志(黄金规则 10 补漏)。
+
+| 项 | 内容 |
+|---|---|
+| **目标** | 用户裁决:消息不入库(留 Matrix);**文件与成果入库**,右侧栏按会话直接加载;每个会话/群聊完全独立 |
+| **取证** | 向 `agent-node-lj-test` 发送 18 问(分五块:状态/会话独立/成果入库/前端加载/底层逻辑),原文落盘 `docs/03-interface/容器取证-上云与会话逻辑-20261110.md`;追问 3 问落盘 `容器追问-url为空与会话归属-20261110.md` |
+| **关键事实** | ① `lfYlgU7SkK` 的 `UPDATABLE` 白名单无 `threadRoot`(L235)→ 传了被静默丢弃;② `post_tool_call` payload 无 Matrix 字段(`shell_hooks.py:86-94`);③ 容器**从不写** `<!--fmode:files-->`,实际写 `**已上云(可直接下载)**` + `- url`;④ `AlP56LCKFm` 对象键已是 `user/<userId>/...`,天然绑定用户 |
+| **纠正误判** | 曾据 Q4 快照判断「url 为空是根因」,容器核查后明确当前 ledger 中 `ok:true && url:""` 为 **0 条** —— 该推论作废,已在文档中标注 |
+| **新建云函数** | `fmode-file-hub`(`THXAOGKiPK`),源码 `cloud/functions/fmode-file-hub.js`;actions:`registerFile` / `listBySession` / `listByAgent`;复用 `AgentDeliverable`,新增 8 个字段 |
+| **踩坑①** | Parse 自动建列只在写入时发生 → 查询不存在的列报 PostgreSQL `42703`;处理:捕获后写探测对象建列再重试 |
+| **踩坑②** | `_isMissingColumn` 若按字段名匹配会把任何错误当缺列(查询语句本身含字段名)→ 只认错误码 |
+| **踩坑③** | 幂等查询 `equalTo('artifacts.url')` 永远匹配不到(数组字段)→ 改查顶层 `fileUrl` |
+| **App 侧** | 20MB 限制(`upload-limits.ts`);上传顺序改为**先 S3 再 Matrix**(云盘失败则不发送);群聊补上云(此前**完全不上云**);群聊成果串台 bug 修复(原按成员容器全量取);右栏四页改按 threadRoot 查台账;删除 `declared-files.ts` 改用容器真实交付段落;上云失败接入全局提示 |
+| **验证** | `tsc` ✅ / `npm test` **1466 SUCCESS**(基线 1459,+7)/ `npm run build` ✅;云函数鉴权 401、参数 400、外链拒绝、幂等 `duplicated`;**真实端到端**:签名→PUT OBS(200)→回读(200)→registerFile→listBySession 全通;**会话隔离**:另一会话查同用户返回 `files: []`;测试数据已清理 |
+| **待容器** | `docs/03-interface/容器任务书-成果按会话归属-20261110.md`:adapter 落盘 thread_id → hook 读出 → 改调 `registerFile` |
+| **留痕** | `docs/00-tracking/2026-11-10-修改留痕-文件台账与按会话隔离.md`;契约 `docs/03-interface/api/API-契约.md` §2.2b |
+
+---
+
+## 2026-11-11 · 合并 origin/master(4c4d592)到 feat/file-hub-session-isolation
+
+| 项 | 内容 |
+|---|---|
+| **合并对象** | `origin/master` 单提交 `4c4d592`「双端认领交互修复 + 组织准入 + 管理员成员管理」,68 文件 / +6185 行;合并基 `cc58e96`;19 个文件冲突 |
+| **冲突性质** | 13 个为可机械合并(两边各自加导入 / 脚本 / 测试),6 个是同一问题两边各修过一轮(成果识别、附件气泡、端切换、计时口径、群成果来源) |
+| **用户裁决 D3+D4** | **纯静默自动跳转**(本项目侧):不读写 `fmode_ui_mode_override`,`detectUiMode()` 视口优先、UA 兜底;双端外壳 400ms 防抖后 `navigateByUrl`。master 的 `watchViewport`/`suggestedMode`/`dismissHint`/`switchMode` 提示栏全部移除 |
+| **用户裁决 D1** | **只认交付段**(本项目侧):`**已上云(可直接下载)**` 段 + 正则兜底;继续不引入 `declared-files.ts`(该文件已删除) |
+| **用户裁决 D2** | **本地先插文件气泡**(本项目侧):`uploadPending` 保留 `local-file-*`,同时修复自动合并产出的**二次 `api.uploadFile` 上传**(会编译失败)与 `_cloudUrls.set()` 写入点丢失 |
+| **用户裁决 D5** | **混合**:读数走 master 的 `elapsedSeconds()`(逐秒、容器耗时分离),叠加本项目侧的超时收尾(`waitingTooLong`)与「空回合不留占位」 |
+| **用户裁决 D6** | **群成果三路 merge**:台账 `_dbFiles` + 会话记录 `deliverableUrls` + 群内 agent 附件,与单聊 `agentDeliverables` 同口径 |
+| **僵尸代码清理** | 移动端群聊有两段 `groupDeliverables` 并存(台账那路静默失效)→ 合并为一份三路 merge;删掉只为提示栏服务的 `goMobile`/`dismissHint`/`resetToAuto`(桌面)与 `goDesktop`/`resetToAuto`(移动);外壳补回 `ngOnDestroy` |
+| **测试调整** | `device.service.spec` 的「跨端提示」整组按裁决重写为「纯自动识别(视口优先)」5 条;计时用例统一走 `elapsedSeconds()`;附件用例断言改为「本地先插」 |
+| **验证** | `tsc --noEmit`(app + spec)✅ / `npm run build` ✅ / `npm test` **1522 SUCCESS**(合并前本项目基线 1466) |
+| **未验证项** | 浏览器双端实测(自动跳端、附件气泡是否出现重复气泡)需在 `agent-node-lj-test` 容器上人工确认 |

+ 204 - 0
docs/02-design/plans/2026-11-10-文件上云完整方案.md

@@ -0,0 +1,204 @@
+# 文件上云完整方案(2026-11-10)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-10 |
+| 范围 | **只做「上云」这一环**。计量与收费明确排除(用户裁决:后续再做) |
+| 测试容器 | `agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`) |
+| 目标 | 用户不提「上传」二字,产出文件也能自动上云、App 成果栏可见 |
+
+---
+
+## 一、目标逻辑(用户定义)
+
+```
+① 用户发出业务需求(不提上传/云盘/链接)
+② 容器产出文件
+③ 文件自动上云 → 拿到 https 在线地址
+④ 成果登记(report + enqueueArtifact)
+⑤ 回复正文里带出 https
+⑥ App 解析链接 → 成果栏展示
+```
+
+**硬性要求**:③④⑤ 全部自动完成,不依赖用户提示,不依赖模型「记得」。
+
+---
+
+## 二、已确认的现状(实测,非推测)
+
+### 2.1 已经通的
+
+| 环节 | 证据 |
+|---|---|
+| 容器产出文件 | ✅ 74 行 Markdown |
+| **自动上云** | ✅ `https://s3.fmode.cn/.../minutes-template-2026-11-16.md` |
+| 文件可下载 | ✅ HTTP 200,2998 字节,内容正确 |
+| 成果表登记 | ✅ 自动新增 1 条 |
+| hooks 重启存活 | ✅ 重启后仍生效 |
+| 判定「成果 vs 缓存」 | ✅ 扩展名白名单,不看用户措辞 |
+
+**结论:上传链路本身是通的。**
+
+### 2.2 断掉的
+
+| 环节 | 问题 | 根因 |
+|---|---|---|
+| **回复带 https** | ❌ 只给 `/opt/data/...` 本地路径 | `transform_llm_output` 接口对不上(见 2.3) |
+
+### 2.3 transform_llm_output 的两个断点(容器读源码确认)
+
+**断点 A:hook 拿不到正文**
+
+```python
+# shell_hooks._payload_fields():只有这些放顶层
+tool_name / tool_input / session_id / cwd / profile
+# 其余全塞进 extra
+{"hook_event_name": "...", "extra": {"response_text": "...", ...}}
+```
+而 hook 代码读的是 `payload.get("response_text")` → **永远空串**。
+
+**断点 B:输出格式不对**
+
+```python
+def _parse_context(data):
+    context = data.get("context")     # ← 只认 context 键
+```
+hook 返回 `{"response_text": ...}` → 解析成 `None` → `fail_closed=False` → **静默丢弃,无任何日志**。
+
+**所以现象是「上传成功、正文无链接」**:上传是 hook 的副作用(先 PUT 再拼文本),拼文本那一步被 Hermes 丢了。
+
+---
+
+## 三、方案
+
+### 3.1 决策:容器侧「自己写链接」,不走 hook
+
+**理由**:断点 A/B 都要动 Hermes 内部契约(`shell_hooks` / `turn_finalizer`),容器自己都说「对返回值格式没十足把握,不敢拍胸脯」。
+
+对比:
+
+| 方案 | 可靠性 | 风险 |
+|---|---|---|
+| 修 hook(改 `_payload_fields` + `_parse_context` 契约) | 依赖 Hermes 内部实现 | 高,可能改坏 |
+| **容器自己把链接写进正文** | **100% 可控** | 低 |
+
+**做法**:容器写完文件后,执行
+```
+push 脚本 → 拿到 https → curl -I 验证 200 → 把 https 写进回复正文
+```
+失败时**明确说「文件已生成但未上云」并给原因**,不拿本地路径冒充交付。
+
+### 3.2 产出文件:临时中转 + 立即删除
+
+| 步骤 | 做法 |
+|---|---|
+| 1. 写入 | 写到 `/opt/data/work/`(不强制改 `/tmp`,避免破坏现有工具链) |
+| 2. 上传 | `push` 脚本:report → enqueue → drain → **curl 回读比对 md5** |
+| 3. **清理** | **上传成功后立即删除本地源文件** |
+| 4. 登记 | 成果表存 `https url` + **原始文件名**(`name` 字段),不存本地路径 |
+| 5. 回复 | 正文给 https;**永不出现** `/opt/data/...` |
+
+> ⚠️ **必须保留的一步**:服务端契约要求 `report`/`enqueueArtifact` 收**容器本地路径**,
+> drain 要 `open(路径)` 才能 PUT。所以「完全不落盘」做不到,
+> 但可以做到**秒级中转、上传后即删**,本地空间不累积。
+
+### 3.3 用户上传文件(App → 容器):双通道
+
+容器读源码给出的硬约束:
+
+```python
+# adapter.py:2136
+if url and not str(url).startswith("mxc://"):
+    logger.warning("Rejecting inbound media ... non-MXC URL")
+    return          # ← 媒体事件里放 https 会被直接丢弃
+```
+
+| 场景 | 通道 | App 改动 |
+|---|---|---|
+| 小文件(≤20MB) | `m.file` / `m.image`,`url` 字段放 **`mxc://`** | **不改**(现状即是) |
+| 大文件 / 需长期保留 | **先上云 → 正文纯文本放 https** | 需改 |
+
+**关键**:https 只能放**纯文本 body**,绝不能放进媒体事件的 `url` 字段。
+
+---
+
+## 四、实施清单
+
+### 4.1 容器侧(3 项)
+
+| # | 动作 | 验收标准 |
+|---|---|---|
+| C-1 | 产出文件后:push → 验证 200 → **正文写 https** | 回复里能搜到 `https://s3.fmode.cn/` |
+| C-2 | PUT 成功后**删除本地源文件** | `/opt/data/work/` 不再累积 |
+| C-3 | 成果表 `name` 存原始文件名,`url` 存 https | App 显示中文名 + 可下载 |
+
+**不做**:修 `transform_llm_output` hook(风险高、收益低)
+
+### 4.2 App 侧(2 项)
+
+| # | 动作 | 说明 |
+|---|---|---|
+| A-1 | 大文件上传:先传云端 → 正文发 https | 纯文本,不进媒体事件 |
+| A-2 | 成果栏正确显示「原始文件名」 | 已有 `prettifyStorageKey`,需验证 |
+
+### 4.3 不做(明确排除)
+
+- ❌ 存储计量
+- ❌ 计费 / 配额
+- ❌ 改云函数加 base64 直传 action
+
+---
+
+## 五、验收方式
+
+### 5.1 核心验收(每次必跑)
+
+**提示词刻意不含任何上传暗示**:
+
+```
+帮我写一份下周的团队例会纪要模板,Markdown 格式,包含议程、决议、待办三部分。
+```
+
+| 检查项 | 通过标准 |
+|---|---|
+| ① 回复带 https | 正文里能搜到 `https://s3.fmode.cn/` |
+| ② 链接可访问 | `curl` 返回 200 + 内容非空 |
+| ③ 成果表新增 | 自动 +1,`name` 是原始文件名 |
+| ④ 本地已清理 | 容器侧 `/opt/data/work/` 无残留 |
+| ⑤ 无本地路径泄露 | 正文不含 `/opt/data/` |
+
+### 5.2 可复用脚本
+
+新增 `scripts/verify-upload.mjs`(参数化容器 ID 与提示词),任意新容器接入时直接跑:
+
+```bash
+node scripts/verify-upload.mjs --agent agent-node-lj-test
+```
+
+---
+
+## 六、新容器接入规范(可复用)
+
+任何新容器接入,必须逐项打勾:
+
+```
+□ 1. 平台绑定核对:agentMxId / roomId 与 FmodeAgent 记录一致
+□ 2. config.yaml hooks 挂载(post_tool_call 盯 write_file/patch/execute_code/terminal)
+□ 3. 判定规则:成果 vs 缓存(扩展名白名单,不看用户措辞)
+□ 4. push 脚本:report → enqueueArtifact → drain → md5 回读验证
+□ 5. 回复格式:带 https、不带本地路径
+□ 6. 本地清理:上传后删源文件
+□ 7. 跑 verify-upload.mjs 全绿
+```
+
+---
+
+## 七、已知风险与限制(如实披露)
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | 本地无法完全零写入 | 服务端契约要求本地路径,只能做到「秒级中转 + 即删」 |
+| 2 | 大文件走正文链接 | 容器需 `curl` 读取,非原生附件;20MB+ 才走这条 |
+| 3 | `transform_llm_output` 仍是坏的 | 不修,改由容器自己写链接;但该 hook 若日后启用需重新验证 |
+| 4 | 队列可能堆积 | 曾出现同源 job 重复 3 次,需容器侧按 source 去重 |
+| 5 | 计量与收费 | **本方案不含**,后续单独做 |

+ 43 - 0
docs/03-interface/api/API-契约.md

@@ -128,6 +128,49 @@ interface DeliverableGroup {
 }
 ```
 
+### 2.2b `THXAOGKiPK` — 文件台账(按会话隔离)✅ 2026-11-10 新建
+
+> **为什么要新函数**:`lfYlgU7SkK` 的 `report` 用 `UPDATABLE` 白名单写字段(源码 L235),里面没有 `threadRoot` —— 容器传了也被静默丢弃,成果无法归属到会话。用户裁决:现有云函数**一行不动**,新建独立函数。
+>
+> **职责**:消息不入库,只有**文件与成果**入库,让右侧栏能按会话直接加载。只存访问链接与元信息,不存文件内容。
+>
+> **Function.name**:`fmode-file-hub` **源码**:`cloud/functions/fmode-file-hub.js`
+
+| 操作 | `params` | 返回 |
+|---|---|---|
+| 登记文件 | `{ action:'registerFile', threadRoot, name, url, key?, mimeType?, size?, uploadedBy, agentId?, roomId?, source? }` | `{ code, id, duplicated? }` |
+| 按会话查 | `{ action:'listBySession', threadRoot, limit?, before? }` | `{ code, threadRoot, files: SessionFile[], count }` |
+| 按容器查 | `{ action:'listByAgent', agentId?, limit?, before? }` | `{ code, files: SessionFile[], count }` |
+
+**`SessionFile`**
+
+```ts
+interface SessionFile {
+  id: string;
+  threadRoot: string;      // 会话归属(App 会话 id = Matrix thread 根)
+  agentId: string;
+  roomId: string;
+  name: string;
+  size: number;            // 未知为 -1
+  mimeType: string;
+  url: string;             // https://s3.fmode.cn/...
+  storageKey: string;
+  uploadedBy: 'user' | 'agent';
+  status: string;
+  deliveredAt: number;     // epoch 毫秒
+}
+```
+
+**约束**
+
+- `url` 必须是 `https://s3.fmode.cn/` 或 `https://file-cloud.fmode.cn/`,否则 `400`
+- `size > 20MB` → `400`(与 App 侧 `upload-limits.ts` 同口径)
+- 幂等:同一 `(user, threadRoot, url)` 重复登记返回 `{ id, duplicated: true }` —— **这是成功,不是错误**
+- 鉴权:token 查 `_Session`;`user` 由服务端确定,**不接受客户端传入**
+- 写入 `AgentDeliverable`,新增字段 `threadRoot` / `fileUrl` / `uploadedBy` / `fileName` / `fileSize` / `mimeType` / `storageKey` / `roomId`
+
+---
+
 ### 2.3 `RP5Ht55NeX` — Matrix 身份开通(provision)✅
 
 | 操作 | `params` | 返回 |

+ 121 - 0
docs/03-interface/容器任务书-成果按会话归属-20261110.md

@@ -0,0 +1,121 @@
+# 容器任务书:成果按会话归属(2026-11-10)
+
+> 本文件是交给容器的完整任务书,可直接下发。
+> 测试容器:`agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`),**不要动 `agent-node-app-lj-test-v1`**。
+
+## 一、为什么要改
+
+App 右侧栏要「每次直接按会话从数据库加载文件」,前提是**每条文件记录知道自己属于哪个会话**。
+
+现状核对(你上一轮 Q9/Q10 的原话):
+
+> 「是我(agent 侧的 hook 脚本)调云函数写,不是 App 写。」
+> 「payload 不含 `threadRoot`、`sessionId`、`roomId` 任何一项……云端无法按会话归属,只能靠 agentId + 时间。」
+
+老云函数 `lfYlgU7SkK` 的 `report` 用 `UPDATABLE` 白名单写字段(源码第 235 行),**里面没有 `threadRoot`**——你就算传了也会被静默丢弃。
+
+## 二、目标
+
+```
+产出文件 → 上云拿 https → 调新函数 registerFile(带 threadRoot)→ App 右侧栏按会话可见
+```
+
+老函数 `lfYlgU7SkK` **容器侧不再调用**(函数本身保留,只是链路切换)。
+
+## 三、改造任务
+
+### 任务 1 · 拿到当前回合的 threadRoot
+
+你的 `post_tool_call` payload 里**没有** Matrix 事件字段(`/opt/hermes/agent/shell_hooks.py:86-94` 只塞了 tool_name / tool_input / session_id / cwd / profile / extra)。
+
+但 `adapter.py:2015` 是能拿到的:
+
+```python
+thread_id = relates_to.get("event_id") if relates_to.get("rel_type") == "m.thread" else None
+```
+
+**要做的事**:在 `adapter.py:2066`(`build_source(..., thread_id=thread_id, ...)`)之后,把这个 `thread_id` 连同 `session_id` 写进一个 session 级状态文件,例如:
+
+```
+/opt/data/session_threads.json
+{ "<session_id>": "<thread_id>", ... }
+```
+
+然后 hook 按 payload 里的 `session_id` 读出来。
+
+**验收**:跑一个回合后,`cat /opt/data/session_threads.json` 能看到当前会话的 `session_id → thread_id` 映射。
+
+### 任务 2 · 改调新函数
+
+新云函数:**`THXAOGKiPK`**(`Function.name = fmode-file-hub`)
+
+```
+POST https://server.fmode.cn/api/functions
+Header: X-Parse-Application-Id: ncloudmaster
+Body: {
+  "id": "THXAOGKiPK",
+  "token": "<FMODE_SESSION_TOKEN>",
+  "params": {
+    "action": "registerFile",
+    "threadRoot": "<任务 1 拿到的 thread_id>",
+    "agentId": "agent-node-lj-test",
+    "roomId": "<当前房间 id>",
+    "name": "<原始文件名>",
+    "url": "<https://s3.fmode.cn/...>",
+    "key": "<云盘对象键>",
+    "mimeType": "<文件类型>",
+    "size": <字节数>,
+    "uploadedBy": "agent",
+    "source": "<容器内原始路径>"
+  }
+}
+```
+
+返回:`{"code":200,"id":"..."}`;重复登记同一 URL 返回 `{"code":200,"id":"...","duplicated":true}`(**这是成功,不是错误**)。
+
+**位置**:`fmode-artifact-push.py` 里现在调 `report` 的地方。保留「上传 + md5 回读」那套逻辑不动,只把**登记**这一步换成上面这个调用。
+
+**`threadRoot` 拿不到时**:不要用 `agentId` 或时间戳凑一个假值。**直接跳过登记,并在 stdout 打一行 `SKIP register: no threadRoot`** —— 没有会话归属的文件进了台账就是脏数据,比不登记更糟。
+
+### 任务 3 · 回复正文格式保持不变
+
+App 现在认这两种(2026-11-10 已用你的实际输出校准):
+
+- Matrix 原生附件 `m.file`
+- 正文里的交付段落:`**已上云(可直接下载)**` + `- https://s3.fmode.cn/...` 列表
+
+**不要**写 `<!--fmode:files-->`(你确认过从没实现,App 那条分支已删除)。
+**不要**在正文写本地路径(`/opt/data/...`、`本地路径:`)。
+
+## 四、验收(逐项贴原始输出)
+
+| # | 检查 | 命令或方式 | 通过标准 |
+|---|---|---|---|
+| 1 | threadRoot 落盘 | `cat /opt/data/session_threads.json` | 有当前会话的映射 |
+| 2 | 登记调用成功 | 产出文件后贴 push 脚本 stdout | 出现 `REGISTER ok id=<objectId>` |
+| 3 | 台账有记录 | 我来 curl `listBySession` 验证 | 返回该文件,`threadRoot` 正确 |
+| 4 | 不再写本地路径 | 贴最终回复正文 | 无 `/opt/data/`、无「本地路径」 |
+| 5 | 系统文件不登记 | 贴 hook 跳过的文件清单 | `sessions.json`、`SKILL.md`、`.env` 等不在内 |
+
+**第 3 项是唯一有效验收** —— 只看「调用返回 200」不算,必须我能按会话查到这条记录。
+
+## 五、完成后请回报
+
+1. 任务 1 改了哪个文件、哪几行,贴改动后的代码
+2. 任务 2 改了 `fmode-artifact-push.py` 的哪几行,贴改动前后对比
+3. 一次完整回合的 push 脚本 stdout 全文
+4. `cat /opt/data/session_threads.json` 的输出
+5. `versions`:三个文件(`adapter.py`、`fmode-artifact-push.py`、`fmode-deliverable-hook.py`)改动后的 `ls -l` 时间
+
+## 六、纪律
+
+- 只改上面点名的文件,**不要动** Matrix/飞书通道配置、`.env`、`config.yaml` 的 hooks 段
+- **不要**为了「看起来成功」而伪造 threadRoot 或 URL
+- 失败就如实报,贴原始报错
+- 改完需要重启 gateway 让 adapter 改动生效;重启前先 `ps` 打印完整 cmdline,**由人来决定重启方式**,不要自己 `docker restart`
+
+## 七、不在本次范围
+
+- 老成果数据回填(用户裁决:只保证新数据)
+- 修改老云函数 `lfYlgU7SkK`
+- 消息入库(消息继续留在 Matrix)

+ 1751 - 0
docs/03-interface/容器取证-上云与会话逻辑-20261110.md

@@ -0,0 +1,1751 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T07:14:32.806Z
+- 房间:`!feLeX9WHV4QAvaKzW1RUYKh09EMC34CYhFzNiylZiy0`(临时房间,可保留查阅)
+- 消息统计:正文 26 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+请按 Q1 到 Q18 编号逐条回答下面五个部分。
+
+回答要求(很重要):
+1. 能贴命令输出就贴原文,不要总结。只写结论、没有命令输出的,算未回答。
+2. 查不到的,直接写「不确定」,不要猜。猜出来的答案对我们有害。
+3. 全程只读:不要改配置、不要重启、不要安装任何东西。
+4. 不要产出任何文件,也不要调用 Report。
+
+第一部分 当前状态
+Q1: 你当前运行的进程是什么?贴 ps -o pid,lstart,cmd 或等价输出,要能看到启动时间。
+Q2: 这三个组件现在在不在?贴路径和 ls -l 的修改时间:fmode-artifact-push.py、plugin-deliverable-inject、fmode-deliverable-hook。
+Q3: 上面这些组件是被哪个进程加载的?把进程启动时间和文件修改时间放在一起对比,谁更晚。
+Q4: 你最近一次成功调用成果相关云函数(report 或 enqueueArtifact)是什么时候?贴 state.db 或日志里的最后一条记录原文。
+
+第二部分 会话独立性
+Q5: 你怎么区分「同一个 App 会话」和「新建的临时房间」?贴你代码里做这个判断的那几行。
+Q6: session_key 的完整结构是什么?thread_id 在什么情况下是空的、什么情况下非空?
+Q7: 同一时刻来了两个不同会话的请求,你的工作目录和上下文会互相串吗?贴隔离的实现代码。
+
+第三部分 成果分配与入库
+Q8: 产出文件后,你判断「哪些文件算成果」的规则原文是什么?把扩展名白名单和排除清单都贴出来。
+Q9: 成果记录是你写进数据库,还是 App 写?如果是你写,贴出调用代码:函数 id、action、参数。
+Q10: 你写入成果时,怎么表示这个成果属于哪个会话?有没有带 threadRoot、sessionId 或 roomId?
+Q11: 历史上你调用 deliverable-report 一共多少次、成功几次?贴日志统计结果。
+
+第四部分 App 如何加载
+Q12: 你发文件时,除了 m.file 附件,还会在正文里写什么?有没有写 <!--fmode:files ...--> 这种结构化标记?
+Q13: 你认为 App 是从成果表读文件,还是从消息正文里找链接?你的依据是什么?
+Q14: 一个回合你一共发几条消息?文件链接出现在第几条?
+
+第五部分 底层请求与异常
+Q15: 把你上云这条链路的完整代码贴出来,也就是 fmode-artifact-push.py 的全文。
+Q16: 上传完怎么校验成功?贴 md5 回读那段代码。
+Q17: 本地源文件在什么时机删除?如果失败会留下什么?
+Q18: 生成链接失败时你在正文里写什么?我们观察到你写过「本地路径:(本地工作目录)」,贴出产生这句话的代码。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我会逐条用真实命令输出来回答。先只读取证。
+```
+
+### 2【prose】
+
+```text
+```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+
+### 3【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+
+### 4【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+
+### 5【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+
+### 6【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+```
+
+### 7【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+
+### 8【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+
+### 9【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+
+### 10【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+
+### 11【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+
+### 12【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+
+### 13【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+
+### 14【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×2)
+```
+
+### 15【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×3)
+```
+
+### 16【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×4)
+```
+
+### 17【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×5)
+```
+
+### 18【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×5)
+```
+cd /opt/data; echo "=== Q10: how deli...
+```
+```
+cd /opt/data; echo "=== hook: functio...
+```
+```
+
+### 19【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×5)
+```
+cd /opt/data; echo "=== Q10: how deli...
+```
+```
+cd /opt/data; echo "=== hook: functio...
+```
+```
+cd /opt/data; echo "=== md5 readback ...
+```
+📖 Reading fmode-deliverable-hook.py L500-569
+```
+
+### 20【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×5)
+```
+cd /opt/data; echo "=== Q10: how deli...
+```
+```
+cd /opt/data; echo "=== hook: functio...
+```
+```
+cd /opt/data; echo "=== md5 readback ...
+```
+📖 Reading fmode-deliverable-hook.py L500-569
+📖 Reading fmode-deliverable-hook.py L320-359
+💻 terminal
+```
+cd /opt/data; echo "=== Q15: fmode-ar...
+```
+```
+
+### 21【prose】
+
+```text
+...
+```
+
+### 22【prose】
+
+```text
+* ```
+echo "=== find components ==="; for p...
+```
+```
+echo "=== /proc/18767 environ (marker...
+```
+```
+echo "=== config.yaml hooks/plugins s...
+```
+```
+echo "=== Q4 state.db / logs: deliver...
+```
+```
+echo "=== sessions config ==="; grep ...
+```
+```
+cd /opt/data; echo "=== state.db tabl...
+```
+```
+cd /opt/data; echo "=== grep AgentDel...
+```
+```
+cd /opt/data; echo "=== fmode-deliver...
+```
+```
+echo "=== Q12/Q13/Q14: plugin-deliver...
+```
+📖 Reading __init__.py
+📖 Reading fmode-deliverable-hook.py L36-165
+💻 terminal
+```
+cd /opt/data; echo "=== hook.log tail...
+```
+```
+cd /opt/data; echo "=== ledger stats ...
+```
+```
+cd /opt/data; echo "=== failed ledger...
+```
+```
+cd /opt/data; echo "=== Q4 state.db: ...
+```
+```
+cd /opt/data; grep -nEi 'MATRIX_AUTO_...
+```
+```
+cd /opt/hermes; echo "=== build_sessi...
+```
+```
+cd /opt/hermes; grep -rn 'auto_thread...
+```
+```
+cd /opt/hermes; ls -d gateway/*matrix...
+```
+```
+cd /opt/hermes; sed -n '2035,2085p' p...
+```
+```
+cd /opt/hermes; sed -n '640,700p' gat...
+```
+```
+cd /opt/hermes; sed -n '2330,2360p' g...
+```
+```
+cd /opt/data; python3 -c " ...
+``` (×5)
+```
+cd /opt/data; echo "=== Q10: how deli...
+```
+```
+cd /opt/data; echo "=== hook: functio...
+```
+```
+cd /opt/data; echo "=== md5 readback ...
+```
+📖 Reading fmode-deliverable-hook.py L500-569
+📖 Reading fmode-deliverable-hook.py L320-359
+💻 terminal
+```
+cd /opt/data; echo "=== Q15: fmode-ar...
+```
+```
+cd /opt/data; echo "=== Q12: does hoo...
+```
+```
+cd /opt/data; echo "=== Q14: how many...
+```
+```
+
+### 23【prose】
+
+```text
+进展中,3 分钟了,快了。 — iteration 21/500, waiting for provider response (streaming)
+```
+
+### 24【prose】
+
+```text
+```
+cd /opt/data; ls -l /opt/data/scripts...
+```
+```
+cd /opt/data; echo "=== Q3 evidence: ...
+```
+```
+
+### 25【prose】
+
+```text
+---
+
+**提示**:以下全部结论均来自本次本次会话的只读命令输出。第 4 问要求的「state.db 里最后一条成果云函数记录」——state.db 里没有云函数调用表,实际凭据在 hook 自己的 ledger 和 hook.log 里,我按实给出。
+
+## 第一部分 当前状态
+
+### Q1 当前进程
+
+```
+Tue Nov 10 15:10:35 CST 2026
+    PID                  STARTED CMD
+      1 Mon Nov  9 13:04:56 2026 /package/admin/s6/command/s6-svscan -d4 -- /run/service
+     17       1 Mon Nov  9 13:04:59 2026 /bin/sh -e /run/s6/basedir/scripts/rc.init top /opt/hermes/docker/main-wrapper.sh gateway run
+     31       1 Mon Nov  9 13:04:59 2026 s6-supervise main-hermes
+    134      28 Mon Nov  9 13:05:07 2026 /opt/hermes/.venv/bin/python3 /opt/hermes/.venv/bin/hermes dashboard --host 0.0.0.0 --port 9119 --no-open  python3.13 /usr/bin/python3.13
+  18767     151 Tue Nov 10 13:54:48 2026 /opt/hermes/.venv/bin/python3 /opt/hermes/.venv/bin/hermes gateway run --replace
+```
+
+真正跑我这个回合的是 **PID 18767**(gateway run),启动时间 **Tue Nov 10 13:54:48 2026**。
+
+### Q2 三个组件在不在
+
+```
+-rwxr-xr-x 1 hermes hermes 11139 Nov 10 12:55 /opt/data/scripts/fmode-artifact-push.py
+mtime=2026-11-10 12:55:53.623871030 +0800  size=11139
+drwxr-xr-x 2 hermes hermes 4096 Nov 10 13:43 /opt/data/plugins/plugin-deliverable-inject
+mtime=2026-11-10 13:43:05.229643456 +0800  size=4096
+-rw-r--r-- 1 hermes hermes 34344 Nov 10 13:43 /opt/data/scripts/fmode-deliverable-hook.py
+mtime=2026-11-10 13:43:24.705692589 +0800  size=34344
+```
+
+三个全部存在。另外 plugin-deliverable-inject 目录内实际文件:`__init__.py`(5410 B,13:43:05)、`plugin.yaml`(436 B,13:11)。
+
+### Q3 谁加载、启动时间 vs 修改时间
+
+加载方式(config.yaml 原文):
+
+```
+62:  post_tool_call:
+63-    - matcher: write_file|patch|execute_code|terminal
+64:      command: python3 /opt/data/scripts/fmode-deliverable-hook.py
+65-      timeout: 20
+66:  transform_llm_output:
+67-    - command: python3 /opt/data/scripts/fmode-deliverable-hook.py
+68-      timeout: 180
+```
+
+插件启用行:
+
+```
+43-    - plugin-deliverable-inject
+```
+
+`fmode-artifact-push.py` 不是被常驻加载的,它由 hook 在运行时用 importlib 动态导入:
+
+```
+32:PUSH_SCRIPT = HOME / "scripts" / "fmode-artifact-push.py"
+326:    spec = importlib.util.spec_from_file_location("fmode_push", str(PUSH_SCRIPT))
+```
+
+时间对比(谁更晚):
+
+```
+gateway 启动 : Tue Nov 10 13:54:48 2026
+artifact-push mtime : 2026-11-10 12:55:53   → 早于启动 58 分钟
+hook mtime          : 2026-11-10 13:43:24   → 早于启动 11 分钟
+plugin __init__ mtime: 2026-11-10 13:43:05  → 早于启动 11 分钟
+config.yaml mtime    : 2026-11-10 13:12:39  → 早于启动 42 分钟
+```
+
+**结论:三个文件全部早于 gateway 启动时间,当前进程加载的就是现在磁盘上这份**(hook 是每次事件起子进程读盘,插件是启动时导入),没有「改了文件但进程还跑旧代码」的情况。
+
+### Q4 最近一次成功调用成果云函数
+
+state.db 里没有「成果云函数调用」表(表清单只有 messages/sessions/gateway_* 等,见下),真实调用记录在 hook 自己的 ledger 与 hook.log:
+
+```
+state.db tables:
+async_delegations compression_locks conversation_generations delivery_obligations
+gateway_heartbeats gateway_hygiene_state gateway_routing messages messages_fts
+... sessions session_model_usage session_turn_leases sqlite_primary sqlite_sequence
+state_meta system_prompts
+```
+
+hook.log 最后两条 REPORT/PUSH 原文:
+
+```
+2026-11-10T13:55:36 REPORT did=GqjZAuRb9O agent=agent-node-lj-test title=sessions.json 等2个文件
+2026-11-10T13:55:37 PUSH /opt/data/sessions/sessions.json ok=True url=
+2026-11-10T13:55:38 PUSH /opt/data/work/gap-verify-1794289200/team-weekly-gap-verify.md ok=True url=
+```
+
+ledger.jsonl 最后一条原文:
+
+```
+{"path": "/opt/data/work/gap-verify-1794289200/team-weekly-gap-verify.md", "ok": true,
+ "url": "",
+ "did": "GqjZAuRb9O", "bytes": 1067, "detail": "readback-ok bytes=1067",
+ "session": "20261110_134001_8c82b2df", "ts": 1794290138}
+```
+
+**最近一次成功 = 2026-11-10 13:55:36(report)/ 13:55:38(push 回读校验通过),deliverableId = GqjZAuRb9O。**
+
+## 第二部分 会话独立性
+
+### Q5 怎么区分「同一个 App 会话」和「新建临时房间」
+
+区分依据只有一个:**Matrix 的 chat_id(room_id)**。每个 room 一个独立 session_key,room 不同 = 会话不同。代码出处 `gateway/session.py`:
+
+```
+686-    parts = [_session_key_namespace(profile), source.platform.value, chat_type_slot]
+687-    if source.platform == Platform.SLACK and source.scope_id:
+688-        parts.append(str(source.scope_id))
+689-    if chat_id:
+690-        parts.append(chat_id)
+```
+
+room_id 由 matrix adapter 传入(`plugins/platforms/matrix/adapter.py`):
+
+```
+source = self.build_source(
+    chat_id=room_id, chat_name=identity.display_name, chat_type=chat_type, user_id=sender,
+    user_name=display_name, thread_id=thread_id, chat_topic=identity.room_topic,
+    guild_id=identity.server_name, parent_chat_id=room_id if thread_id else None, message_id=event_id)
+```
+
+实证(state.db 最新 6 条 session 原文):
+
+```
+'agent:main:matrix:dm:!feLeX9WHV4QAvaKzW1RUYKh09EMC34CYhFzNiylZiy0'   thread_id=None  started=2026-11-10 15:10:26
+'agent:main:matrix:dm:!6pMC2VneND1vXLHbXa-NSUbsQge1uZBHTQKzSBAh69w'   thread_id=None  started=2026-11-10 15:09:58
+'agent:main:matrix:dm:!tyUITJljxtWGzZNoxlTlU8y-hklYUMaEsgv_UY_pXK4'   thread_id=None  started=2026-11-10 14:01:57
+'agent:main:matrix:dm:!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU:$approot_mwaa0ov1_k24sn48h:fmode.cn' thread_id='$approot_mwaa0ov1_k24sn48h:fmode.cn' started=2026-11-10 13:55:39
+```
+
+每个房间一条独立 session_key,互不覆盖。**注意:App 每次「新建临时房间」用的是新 room_id,所以在我这边看就是新会话,没有任何共享标识。**
+
+### Q6 session_key 完整结构与 thread_id 空/非空条件
+
+结构(`gateway/session.py:660` 文档字符串原文):
+
+```
+Layout: ``<ns>:<platform>:<chat_type>[:<slack scope_id>][:<chat_id>][:<thread_id>][:<user>]``.
+```
+
+DM 与群聊的拼接顺序不同(代码原文):
+
+```
+691-    # DMs put the participant before the thread; groups/threads put it after.
+692-    user_part = [str(participant_id)] if isolate_user and participant_id else []
+693-    thread_part = [thread_id] if thread_id else []
+694-    parts += user_part + thread_part if is_dm else thread_part + user_part
+```
+
+thread_id 为空/非空判定(matrix adapter 原文):
+
+```
+2053-        # Real thread roots are preserved above; synthetic roots (this event) follow policy: DM
+2054-        # @mention threads / DM auto-thread, or room auto-thread unless session_scope pins the room.
+2055-        if not thread_id:
+2056-            if is_dm:
+2057-                synthetic = (self._dm_mention_threads and is_mentioned) or self._dm_auto_thread
+2058-            else:
+2059-                synthetic = self._matrix_session_scope == "thread" or (
+2060-                    self._matrix_session_scope != "room" and self._auto_thread)
+2061-            if synthetic:
+2062-                thread_id = event_id
+```
+
+配置默认值:
+
+```
+869-        self._auto_thread: bool = self._extra_truthy(config, "auto_thread", "MATRIX_AUTO_THREAD", "true")
+870-        self._dm_auto_thread: bool = _env_truthy("MATRIX_DM_AUTO_THREAD", "false")
+872-        raw_session_scope = str(_extra_or_secret(config.extra, "session_scope", "MATRIX_SESSION_SCOPE", "auto"))
+```
+
+- **thread_id 非空**:消息本身带真实 thread 根(`m.relates_to` 的 m.thread),或命中上面 synthetic 分支被赋成 `event_id`。
+- **thread_id 为空**:DM 且 `MATRIX_DM_AUTO_THREAD=false`(默认),且消息不是 thread 回复。我这个会话就是这种:`.env` 里没有设 MATRIX_DM_AUTO_THREAD,取默认 false,所以 `thread_id=None`。
+
+### Q7 两个会话同时来请求会不会串
+
+**不会串 session 键,工作目录也不隔离。** 两件事要分开说:
+
+隔离实现只有 session_key 这一层(`gateway/platforms/base.py:2340`):
+
+```
+    def _source_session_key(self, source: "SessionSource") -> str:
+        extra = self.config.extra
+        return build_session_key(
+            source, group_sessions_per_user=extra.get("group_sessions_per_user", True),
+            thread_sessions_per_user=extra.get("thread_sessions_per_user", False),
+            profile=self._session_key_profile(source))
+```
+
+串行化靠 session 级锁(state.db 里 `session_turn_leases` 表 + `gateway/session.py` 的 `_SessionFlight`):
+
+```
+class _SessionFlight:
+    def __init__(self) -> None:
+        self.event = threading.Event()
+```
+
+但我**没有找到任何 cwd 隔离代码**:gateway 进程 cwd 固定为 `/opt/data`(`/proc/18767/cwd -> /opt/data`),terminal 工具是同一台机器同一 shell 会话。所以:**上下文(对话历史)按 session_key 隔离,不串;工作目录是共享的 `/opt/data`,两个会话同时写同名文件会互相覆盖。** 这一点我只能确定到「没有隔离代码」,不是「有 bug」。
+
+## 第三部分 成果分配与入库
+
+### Q8 「哪些文件算成果」的规则原文
+
+`fmode-deliverable-hook.py` 里 RULES_DOC 原文(37–70 行):
+
+```
+交付成果(要上云 + 登记成果表)必须同时满足 A、B、C 三条:
+  A. 扩展名属于成果白名单:
+     .html .htm .pdf .docx .doc .xlsx .xls .pptx .ppt .csv .json .md .txt .zip
+     .png .jpg .jpeg .webp .svg .gif .mp4 .mp3 .m4a .wav
+  B. 不在过程缓存目录 / 系统目录(任一命中即拒绝):
+     /tmp/**  /var/tmp/**  /root/**  .cache  /cache/  node_modules/  .venv*/
+     venv/  __pycache__/  .git/  site-packages/  /opt/data/logs/  /opt/data/cron/output/
+     以及 /opt/data/work/**/node_modules/** 这类嵌套
+     **系统文件区(绝不是用户的交付成果)**:
+     /opt/data/skills/**        技能定义(SKILL.md 等)——agent 自己的知识库
+     /opt/data/git-repos/**     git 工作区
+     /opt/data/scripts/**       agent 自己的运维脚本
+     /opt/data/plugins/**       agent 自己的插件代码
+     /opt/data/config.yaml etc. 配置文件
+     /opt/data/.env 等 dotfile 凭据
+     /opt/data/memories/**      记忆文件
+     /opt/data/cron/**          定时任务定义
+     /opt/data/cache/**         各类缓存
+  C. 文件名不含过程缓存特征(任一命中即拒绝):
+     *.bak*  *.tmp  *.temp  *.log  *.pyc  *.pyo  *~  .DS_Store
+     前缀/子串:tmp- temp- test- probe- debug- scratch- ._  .nfs
+     正则:^(a|b|c|d)-result-.*\.md$ (当天回归测试产物)
+             ^.*(-test|\.test|\.spec)\.(py|js|mjs|ts)$
+除上述规则外,以下任一情况直接视为交付成果(显式打标,优先于 B/C,但**不能**绕过系统文件闸门):
+  D1. 写在标记目录里:/opt/data/work/deliverables/**(模型显式选择放这里)
+  D2. 路径出现在本轮 tool_input 的显式标记里(见 MARK_KEYS)
+**系统闸门最高优先**:/opt/data/skills/、/opt/data/git-repos/、/opt/data/scripts/、
+/opt/data/plugins/、/opt/data/memories/、/opt/data/cron/、/opt/data/cache/、
+/opt/data/profiles/、.env/config.* 等点文件,无论是否落在 D1/D2,一律不上云。
+反向:命中任一即一定不上云(黑名单先于白名单)
+  E1. 文本里出现 [cache-only] / #cache-only / CACHE_ONLY=1
+  E2. 文件大小 40MB(聊天通道与成果表都放不下,走 bigfile 网关另说)
+```
+
+对应代码常量:
+
+```
+78:EXT_ALLOW = {".html",".htm",".pdf",".docx",".doc",".xlsx",".xls",".pptx",".ppt",
+    ".csv",".json",".md",".txt",".zip",".png",".jpg",".jpeg",".webp",".svg",".gif",
+    ".mp4",".mp3",".m4a",".wav"}
+85:DIR_DENY_SUBSTR = ("/tmp/","/var/tmp/","/root/","/.cache/","/cache/","/node_modules/",
+    "/.venv","/venv/","/__pycache__/","/.git/","/site-packages/",
+    "/opt/data/logs/","/opt/data/cron/output/","/.fmode/","/.claude/")
+116:NAME_DENY_GLOB = ("*.bak*","*.tmp","*.temp","*.log","*.pyc","*.pyo","*~",".DS_Store","*.part","*.swp")
+117:NAME_DENY_SUBSTR = ("tmp-","temp-","test-","probe-","scratch-","._",".nfs","poor_guard")
+123:MAX_BYTES = 40 * 1024 * 1024
+```
+
+**实证它有在跑**(hook.log 原文):
+
+```
+SKIP /opt/data/.env tool=execute_code reason=system-config-file(.env)
+SKIP /opt/data/state.db tool=terminal reason=ext-not-allowlisted(.db)
+SKIP /opt/data/logs/gateway.log tool=terminal reason=denied-dir(/opt/data/logs/)
+SKIP /opt/data/scripts/fmode-deliverable-hook.py tool=patch reason=system-path(/opt/data/scripts/)
+```
+
+### Q9 数据库记录是谁写
+
+**是我(agent 侧的 hook 脚本)调云函数写,不是 App 写。** 调用代码原文:
+
+```
+BASE = ""
+FN = "lfYlgU7SkK"
+DEFAULT_AGENT_ID = "agent-node-lj-test"
+
+def call(params):
+    body = json.dumps({"id": FN, "token": TOK, "params": params}).encode()
+    req = urllib.request.Request(BASE, data=body, headers={
+        "X-Parse-Application-Id": "ncloudmaster"})
+```
+
+report 的调用(hook 版):
+
+```
+rep = push.call({"action": "report", "agentId": agent_id, "title": title,
+                 "summary": summary,
+                 "artifacts": [{"name": os.path.basename(p), "source": p} for p in live],
+                 "tags": ["auto-deliverable-hook"]})
+did = rep.get("id")
+if rep.get("code") != 200 or not did:
+    ... "report-failed:..."
+```
+
+入队:
+
+```
+push.call({"action": "enqueueArtifact", "deliverableId": did, "agentId": agent_id,
+           "source": p, "filename": safe, "index": idx})
+```
+
+- **函数 id**:`lfYlgU7SkK`(端点 ``,header `X-Parse-Application-Id: ncloudmaster`)
+- **action 顺序**:`report` → `enqueueArtifact` → `pendingArtifacts` → PUT → `ackArtifact`
+- **agentId 实际取到什么**:`_resolve_agent_id()` 返回 `/run/s6/container_environment/FMODE_AGENT_ID`,hook.log 里所有记录都显示 `agent=agent-node-lj-test`,也就是说**我上报的 agentId 是 `agent-node-lj-test`,而不是我 Matrix 身份 `zGDCmgNQcd`**。这是我看到的事实,是否该改需你判断。
+
+### Q10 成果归属哪个会话,有没有 threadRoot/sessionId/roomId
+
+**没有。这是明确的事实。** report 的 payload 里只有 `agentId / title / summary / artifacts[{name,source}] / tags`,**不含 threadRoot、sessionId、roomId 任何一项**:
+
+```
+rep = push.call({"action": "report", "agentId": agent_id, "title": title,
+                 "summary": summary,
+                 "artifacts": [{"name": os.path.basename(p), "source": p} for p in live],
+                 "tags": ["auto-deliverable-hook"]})
+```
+
+session_id 只落在**本地** ledger 条目里(`"session": "20261110_134001_8c82b2df"`),没有上传到成果表。所以云端成果记录**无法按会话归属**,只能靠 agentId + 时间。这是你 Q13/Q14 观察到的现象最可能的根源。
+
+### Q11 历史调用次数统计
+
+```
+ledger lines: 28
+ok= 25 notok= 3
+hook.log  REPORT 行数: 24
+hook.log  PUSH   行数: 22   (其中 ok=True: 22)
+```
+
+3 条失败原文:
+
+```
+{"path": "/opt/data/work/e2e-onboard/新同事入职清单.md", "ok": false, "did": "AvF2SuydAZ",
+ "detail": "put-failed:'ascii' codec can't encode characters in position 68-74: ordinal not in range(128)", ...}
+{"path": "/opt/data/work/e2e-onboard/新同事入职清单.md", "ok": false, "did": "A9m3SE9kRH",
+ "detail": "put-failed:接口回 403 了,这次没成。", ...}
+{"path": "/opt/data/work/e2e-onboard/新同事入职清单.md", "ok": false, "did": "rE7PcsSMst",
+ "detail": "put-failed:服务端回 403,先放一放。", ...}
+```
+
+**计数口径不一致,我不替它圆**:ledger 28 行(25 成功 / 3 失败)、REPORT 24 次、PUSH 22 次全成功。差在 ledger 会计入复用(REUSE)不新建记录的条目,而 REPORT 只在真的新建 deliverable 时打一行。**要精确的「deliverable-report 调用了多少次」,只看这三个数字我给不出唯一答案 —— 这部分我不确定。**
+
+## 第四部分 App 如何加载
+
+### Q12 发文件时正文里写什么,有没有结构化标记
+
+**没有 `<!--fmode:files ...-->` 这类标记。** 我 grep 了两个组件的全部源码:
+
+```
+grep -n -E 'fmode:files|<!--|fmode-files|attachments' hook.py plugin/__init__.py
+→ /opt/data/scripts/fmode-deliverable-hook.py:72:MARK_KEYS = ("deliverable","is_deliverable","deliverables","attach","attachments")
+```
+
+唯一命中 `attachments` 的是上面这个 **输入侧** 的 MARK_KEYS(判断我自己 tool_input 里有没有打标),**输出侧不写任何结构化注释**。
+
+正文里实际追加的是什么,代码原文(plugin `__init__.py:129-133`):
+
+```
+block = [HEADER] + [f"- {u}" for u in missing]
+new_text = text + "\n\n" + "\n".join(block)
+```
+
+`HEADER = "**已上云(可直接下载)**"`。所以正文末尾是一行粗体标题 + 若干条 `- ` **纯文本 URL 列表**。
+
+另外我不用 `MEDIA:` 发成果(插件里有专门删掉指向本地路径的 MEDIA 行的逻辑):
+
+```
+115:    text = re.sub(r"(?m)^[ \t]*MEDIA:[ \t]*(?:[((]|/opt/data|/tmp|/home)[^\n]*\n?", "", text)
+```
+
+**唯一例外**:充值二维码是走 `MEDIA:<qrPath>` 的(那是本地真文件,不属成果)。
+
+### Q13 App 从成果表读,还是从正文找链接
+
+**从我的观察看,App 两者都不完全成立 —— 但我不确定 App 侧实现,只能说我这侧的确定事实:**
+
+1. 我确实把记录写进了成果表(Q9,report 返回 200 带 did);
+2. 但成果记录里**没有 roomId / sessionId / threadRoot**(Q10),云端无法把成果绑回某个 App 会话;
+3. 正文里我**会**写 https 明文(Q12)。
+
+**依据**:你把「本地路径:(本地工作目录)」当异常提出来,说明 App 在正文里找东西;同时 Fmode 平台的成果表设计(`report` 要 `artifacts` 绝对路径、`enqueueArtifact` 回写 `url` 到 artifact)说明表里本该有 url 字段。**两者都可能被用到,App 具体优先读哪边,我没有代码也没法测 —— 这部分写「不确定」。**
+
+### Q14 一个回合发几条消息,链接在第几条
+
+Matrix 单条消息上限与切分(adapter 原文):
+
+```
+368:DEFAULT_MAX_MESSAGE_LENGTH = 16000
+808:    splits_long_messages = True  # send() chunks via truncate_message(max_message_length)
+1391:        for chunk in self.truncate_message(self.format_message(content), self.max_message_length):
+```
+
+**结论:正常一个回合只发 1 条消息**(除非正文超 16000 字符被切块)。成果链接在这一条消息的**末尾**(`text + "\n\n" + "**已上云(可直接下载)**\n- url..."`)。
+
+**所以如果你观察到「链接出现在第 2 条」,那不是我发的两条 —— 我需要你给具体 case 才能确认,这一条我不确定。**
+
+## 第五部分 底层请求与异常
+
+### Q15 fmode-artifact-push.py 全文
+
+文件 11139 字节 / 约 300 行,我贴出**关键全文段**(read_file 受长度限制无法在一次回复内贴完 300 行;下面是我实际读到并核对过的全部核心段。完整文件路径 `/opt/data/scripts/fmode-artifact-push.py`):
+ (1/2)
+```
+
+### 26【prose】
+
+```text
+```
+#!/usr/bin/env python3
+"""fmode-artifact-push.py — 一步做完 report + enqueueArtifact + drain + 回读校验
+
+用法:
+  fmode-artifact-push.py push --files /opt/data/work/a.md [/b.pdf ...] \
+      [--title T] [--summary S] [--agent-id ID] [--no-drain]
+
+链路(缺一不可):
+  ① action=report          -> 建 Deliverable 记录,返回 id == deliverableId
+  ② action=enqueueArtifact -> 入队(deliverableId/agentId/source/filename/index)
+  ③ action=pendingArtifacts -> 取签名 uploadUrl,PUT 文件,ackArtifact(ok)
+  ④ curl publicUrl 回读 -> 字节数/md5 必须与本地产物一致才算成功
+退出码: 0 全部拿到 https 且内容一致; 1 任一步失败(详细信息打 stdout)
+"""
+import argparse, hashlib, json, os, socket, ssl, sys, urllib.error, urllib.request
+
+BASE = ""
+FN = "lfYlgU7SkK"
+DEFAULT_AGENT_ID = "agent-node-lj-test"
+
+def _token():
+    t = os.environ.get("FMODE_SESSION_TOKEN")
+    if t: return t.strip()
+    for p in ("/run/s6/container_environment/FMODE_SESSION_TOKEN",
+              "/opt/data/.fmode/config/user.json"):
+        ...
+
+TOK = _token()
+
+def call(params):
+    body = json.dumps({"id": FN, "token": TOK, "params": params}).encode()
+    req = urllib.request.Request(BASE, data=body, headers={
+        "X-Parse-Application-Id": "ncloudmaster"})
+    ...
+```
+
+各 action 调用点行号(grep 原文):
+
+```
+188:    rep = call({"action": "report", "agentId": agent_id,
+201:        r = call({"action": "enqueueArtifact", "deliverableId": did,
+213:    pend = call({"action": "pendingArtifacts", "agentId": agent_id})
+240:            call({"action": "ackArtifact", "objectId": j["id"], "ok": False, "error": msg})
+244:        ack = call({"action": "ackArtifact", "objectId": j["id"],
+```
+
+### Q16 上传完怎么校验成功 / md5 回读代码
+
+`fmode-deliverable-hook.py:531-543` 原文:
+
+```
+        # 回读:字节数 + md5 必须一致
+        import urllib.request
+        from urllib.parse import quote as _q
+        try:
+            with urllib.request.urlopen(_q(url, safe=":/?&=%@+~!$'()*,;#[]"), timeout=120) as r:
+                got = r.read()
+            same = hashlib.md5(got).hexdigest() == hashlib.md5(data).hexdigest()
+            detail = "readback-ok bytes=%d" % len(got) if same else "readback-mismatch"
+        except Exception as e:
+            same, detail = False, "readback-error:%s" % str(e)[:120]
+
+        res.append({"path": p, "ok": same, "url": url, "did": did, "bytes": len(data), "detail": detail})
+        log("PUSH %s ok=%s url=%s" % (p, same, url))
+```
+
+复用路径另有 `_remote_matches()`:
+
+```
+401:def _remote_matches(url: str, local_md5: str) -> bool:
+402:    """回读线上文件,md5 与本地一致才算复用有效。"""
+409:            h = hashlib.md5(r.read()).hexdigest()
+410:        return h == local_md5
+```
+
+注意:`detail` 只记字节数,**md5 值本身不落日志**,所以事后审计只能看到 `readback-ok bytes=1067`,无法复核 md5。
+
+### Q17 本地源文件何时删除,失败留什么
+
+```
+75:# C-2:上传并回读 md5 一致后删除本地源文件(用户本地空间有限,文件以云端为准)
+76:CLEANUP_LOCAL = True
+
+544:        # C-2 清理:只有回读 md5 与本地产物一致,才删本地源文件
+545:        if same and CLEANUP_LOCAL:
+546:            try:
+547:                os.remove(p)
+548:                log("CLEANUP removed local %s" % p)
+549:            except OSError as e:
+550:                log("CLEANUP-FAIL %s %s" % (p, e))
+```
+
+**时机:PUT + ack + 回读 md5 一致之后,立刻删。**
+**失败留下什么:** 
+- PUT 失败 / 回读 mismatch / 异常 → `same=False` → **不删**,本地文件原样保留,ledger 里记 `ok:false`;
+- 删的过程中 `OSError` → 打 `CLEANUP-FAIL`,文件留下;
+- 更隐蔽的一条:**报告失败(report 没拿到 did)时直接 `return res`,后面 PUT/回读/清理全部不执行**,文件留下但不入账。
+
+hook.log 里成功清理的实证:
+
+```
+2026-11-10T13:55:37 CLEANUP removed local /opt/data/sessions/sessions.json
+2026-11-10T13:34:20 CLEANUP removed local /opt/data/work/app-e2e-1794288808946/app-e2e-1794288808946.md
+```
+
+**顺带一个我看到的副作用(不影响你问的,但值得你知道)**:`/opt/data/sessions/sessions.json` 是 Hermes 自己的会话存储文件,被当成成果推上云然后**删掉了**(它落在 allowlist `.json` 且不在系统闸门里)。这是真事,hook.log 有原文。
+
+### Q18 「本地路径:(本地工作目录)」是哪段代码产生
+
+**关键结论:产生这句话的代码在当前两个文件里都不存在 —— 它是老版本写的,现在的代码只有「删掉它」的清洗逻辑。**
+
+现行代码里提到这个字符串的地方,全是在**清除**:
+
+```
+/opt/data/scripts/fmode-deliverable-hook.py:556:    分三种情况,绝不留下「(本地工作目录)」这类假地址占位:
+/opt/data/scripts/fmode-deliverable-hook.py:568:    # 0) 先删掉遗留的假占位(老版本插件写过;标点后面可能还跟着裸文字)
+/opt/data/scripts/fmode-deliverable-hook.py:569:    text = re.sub(r"[((]本地工作目录[))]", "", text)
+/opt/data/plugins/plugin-deliverable-inject/__init__.py:107:    # 顺带把本地路径整行删掉(绝不留下「(本地工作目录)」这类假地址占位)
+```
+
+插件的整行删除(`__init__.py:109-113`):
+
+```
+    text = re.sub(
+        r"(?m)^[ \t]*(?:\*\*|__)?[ \t]*(?:本地路径|本地文件|本地地址|存放路径|落盘路径)[ \t]*(?:\*\*|__)?[ \t]*[::][^\n]*\n?",
+        "",
+        text,
+    )
+```
+
+**所以:如果我今天还写出「本地路径:(本地工作目录)」,那不是某个模板生成的,是我(LLM)自己按旧习惯手写的字面文本,靠这两条正则事后删掉。** 生成它的代码 = 我自己的措辞,没有代码出处。这个我得认。
+
+---
+
+## 需要你指出的一点矛盾(不猜,交给你判断)
+
+Q4 和 Q10 合起来暴露一个问题:**我上报的 `agentId` 是 `agent-node-lj-test`(旧身份),而 bind 到会话的 `roomId/sessionId` 根本没上传。** 如果 App 是按 agentId + 会话去找成果,那它只能看到「这个 agent 的全部成果」,看不到「这一轮这个房间的成果」—— 这跟你观察到的「App 拿不到文件」方向一致,但我没有 App 代码,**不下结论**。
+
+**全程只读已遵守**:没有改任何配置、没重启、没装东西、没产出文件、没调 Report(本回合所有命令都是 `ps/stat/grep/sed -n/read_file/sqlite ro/urllib 未执行`)。 (2/2)
+```
+
+### 27【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== date ==="; date; echo; echo... ````

+ 156 - 0
docs/03-interface/容器执行-切换文件台账-20261110.md

@@ -0,0 +1,156 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T08:20:21.718Z
+- 房间:`!FvliFImrOQOuEMqmRZh1_gwQ8VXzrwrc_7G--Qd1FdA`(临时房间,可保留查阅)
+- 消息统计:正文 3 段 / 工具过程 2 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+【任务】把成果登记从老函数切到新函数 fmode-file-hub,并带上会话归属
+
+== 背景 ==
+App 右侧栏现在要按会话从数据库加载文件。为此我们新建了一个云函数,函数 id 是 THXAOGKiPK,名字 fmode-file-hub。
+它和老的 lfYlgU7SkK 的区别:多了 threadRoot 字段,能把文件归属到具体会话。
+
+== 已完成的前置(你不需要重做)==
+- 新云函数已上线并验证:registerFile / listBySession / listByAgent 三个 action 都可用
+- 我已经用真实账号测过:取签名 → PUT → 登记 → 按会话查回,全通;会话隔离验证通过
+- 老函数 lfYlgU7SkK 一行未改,保留不动
+
+== 你要做的 ==
+
+第 1 步 · 确认 threadRoot 来源
+你上一轮说 HERMES_SESSION_THREAD_ID 存在但值为空。请先验证:在 App 真实会话里(带 m.relates_to 的 thread),这个变量的值是不是 $xxx。
+执行:env | grep HERMES_SESSION_THREAD_ID
+如果拿到非空值,直接用它做 threadRoot。如果还是空,停下报告。
+
+第 2 步 · 改登记调用
+在 /opt/data/scripts/fmode-artifact-push.py 里:
+- 保留现有的上传逻辑(report 之外的 PUT / 回读 md5 那套)不动
+- 把「登记」这一步改成调用新函数:
+
+POST https://server.fmode.cn/api/functions
+Header: Content-Type: application/json
+Header: X-Parse-Application-Id: ncloudmaster
+Body:
+{
+  "id": "THXAOGKiPK",
+  "token": "<FMODE_SESSION_TOKEN>",
+  "params": {
+    "action": "registerFile",
+    "threadRoot": "<第 1 步拿到的 HERMES_SESSION_THREAD_ID>",
+    "agentId": "agent-node-lj-test",
+    "roomId": "<HERMES_SESSION_CHAT_ID>",
+    "name": "<原始文件名>",
+    "url": "<https://s3.fmode.cn/...>",
+    "key": "<云盘对象键>",
+    "mimeType": "<文件类型>",
+    "size": <字节数>,
+    "uploadedBy": "agent",
+    "source": "<容器内原始路径>"
+  }
+}
+
+返回 {"code":200,"id":"..."} 算成功;{"code":200,"id":"...","duplicated":true} 也是成功(重复登记,正常)。
+返回 code 不是 200 就是失败,如实记录。
+
+第 3 步 · threadRoot 为空时的处理
+如果 HERMES_SESSION_THREAD_ID 为空,不要用 roomId 或时间戳凑一个假值。
+跳过登记,并在 stdout 打一行:SKIP register: no threadRoot
+理由:没有会话归属的文件进了台账就是脏数据,比不登记更糟。
+
+第 4 步 · 不要动的东西
+- 不要改老云函数 lfYlgU7SkK 的任何调用方式(enqueueArtifact / pendingArtifacts / ackArtifact 照旧)
+- 不要写 <!--fmode:files--> 标记(App 已删这条分支)
+- 不要在正文写本地路径(/opt/data/... 或「本地路径:」)
+- 不要动 Matrix / 飞书通道配置
+
+== 验收(逐项贴原始输出)==
+1. env | grep HERMES_SESSION_THREAD_ID 的输出
+2. 改动后 push 脚本的 stdout 全文(要能看到 registerFile 的请求和返回)
+3. 一次真实回合里,我这边 curl listBySession 能查到你登记的文件
+4. 最终回复正文不含 /opt/data/
+
+== 回报格式 ==
+threadRoot 来源 = <变量名和实测值>
+改动文件 = <路径:行号>
+registerFile 返回 = <原样贴 JSON>
+失败项 = <原始报错,没有写「无」>
+
+== 纪律 ==
+- 不要伪造成功
+- 失败就贴原始报错
+- 改完如果需要重启 gateway,先 ps 打印完整 cmdline,由人决定重启方式,不要自己 docker restart
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先确认 threadRoot 的当前状态,并把脚本现状看清楚。
+```
+
+### 2【prose】
+
+```text
+第 1 步实测结果出来了:`HERMES_SESSION_THREAD_ID=` **仍然为空**(本会话是 dm、没有 thread)。按你的纪律,我先停下核对一点,再决定要不要动脚本。
+```
+
+### 3【prose】
+
+```text
+**threadRoot = 空,按你的第 3 步规则止步。** 但有一件必须让你知道的矛盾,否则你验收第 3 条时会出现“脚本没登记,却还能按会话查到文件”的假象。
+
+**逐项原始输出(第 2、3 步均未执行,没有改动任何文件)**
+
+1. `env | grep HERMES_SESSION_THREAD_ID` 原文:
+```
+HERMES_SESSION_THREAD_ID=
+```
+同批对照,证明 env 注入通道本身是通的,只是这一个变量真的没值:
+```
+HERMES_SESSION_CHAT_ID=!FvliFImrOQOuEMqmRZh1_gwQ8VXzrwrc_7G--Qd1FdA
+HERMES_SESSION_MESSAGE_ID=$4v1FvPLpGLb6SVcwSWbT6Mf1dXmG3DmcpJC6XVr1Mig
+HERMES_SESSION_CHAT_TYPE=dm
+```
+
+2. push 脚本 stdout:**没有**,因为没跑 push。
+
+**回报格式**
+
+threadRoot 来源 = `HERMES_SESSION_THREAD_ID`,实测值 = 空字符串(本会话 `HERMES_SESSION_CHAT_TYPE=dm`,无 `m.relates_to` thread)
+
+改动文件 = 无(第 3 步触发,按纪律跳过登记;`fmode-artifact-push.py` 一行未改)
+
+registerFile 返回 = 未调用
+
+失败项 = `HERMES_SESSION_THREAD_ID` 为空字符串,按第 3 步规则止步。原始输出即上面那行 `HERMES_SESSION_THREAD_ID=`(等号后无任何字符,exit=0)。
+
+**两个必须指出的前置问题(都影响你验收)**
+
+- **登记 hook 不是这个脚本。** `/opt/data/scripts/fmode-artifact-push.py` 是手动跑的 CLI,结尾 `FN = "lfYlgU7SkK"`(第 26 行),走的是 report → enqueueArtifact → pendingArtifacts → ackArtifact 老链路。而“我写文件后自动上云并登记”是另一个 hook:`/opt/data/scripts/fmode-deliverable-hook.py`(挂在 `hooks.post_tool_call` + `hooks.transform_llm_output`)。改 py 里的登记步骤,改变不了真实回合里的登记行为——那是两套代码。
+- **我这台机器上真实回合的环境里会不会有 thread 值,用我的 shell 测不出来。** 我这轮是 dm,本来就没有 thread;如果你在 App 里带 `m.relates_to` 的 thread 里发指令,值才有可能是 `$xxx`。所以“在真实 thread 会话里验证”这一步,只能由你在 App 的 thread 里发一条消息触发,我看的是那一轮进程的环境。
+
+**我的建议(等你定,不然就照你说的停在原地)**
+你在 App 的**真实 thread** 里回我一句任意内容,我立刻在那一回合读 `HERMES_SESSION_THREAD_ID`:
+- 有 `$xxx` → 我同时改 `fmode-artifact-push.py` 的 `push()` 登记段**和** `fmode-deliverable-hook.py` 的登记段(老函数 lfYlgU7SkK 的调用照旧保留),`<!--fmode:files-->` 绝不写出来;
+- 还是空 → 继续保持跳过,并在脚本里补齐 `SKIP register: no threadRoot` 这一行,让它成为硬闸门而不是靠我记着。
+
+要我现在先把第 3 步的 `SKIP register: no threadRoot` 闸门落到两个脚本里(不连新函数、不改老链路),还是完全等你确认后一次性改?
+```
+
+### 4【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== HERMES_SESSION_* in my shel... ````
+- `📖 Reading fmode-artifact-push.py 💻 terminal ``` echo "=== 逐项原始输出 ==="; env | grep HER... ````

+ 383 - 0
docs/03-interface/容器执行-成果登记切新函数-20261110.md

@@ -0,0 +1,383 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T08:32:44.094Z
+- 房间:`!LwmzhGGy4J1SY_maAUadmKb5OFWK0Cp6qS9a8il5Jz0`(临时房间,可保留查阅)
+- 消息统计:正文 28 段 / 工具过程 12 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+【任务】让成果登记带上会话归属,切换到新云函数
+
+== 已确认的事实(你上一轮实测的结论,直接采信)==
+- thread_id 在 adapter 层是对的,但 shell hook 子进程里 HERMES_SESSION_THREAD_ID 拿不到(父进程没导出这个 ContextVar)
+- 可靠取法:用 HERMES_SESSION_ID 查 /opt/data/state.db 的 sessions 表 thread_id 列,你实测 105/105 命中
+- 新云函数 THXAOGKiPK(fmode-file-hub)已上线并验证可用
+
+== 你要做的 ==
+
+第 1 步 · 写一个取 threadRoot 的函数
+在你的 hook 脚本里加一个函数,按下面逻辑取:
+  sid = os.environ.get('HERMES_SESSION_ID')
+  若 sid 为空 → 返回 None
+  只读连接 /opt/data/state.db,SELECT thread_id FROM sessions WHERE id=? 
+  查不到或为空 → 返回 None
+贴出你写的函数代码。
+
+第 2 步 · 改登记调用
+真实自动登记走的是 fmode-deliverable-hook.py(你指出的)。在它调用 report 的地方改:
+- 先取 threadRoot;取不到就跳过登记,stdout 打 SKIP register: no threadRoot
+- 取到就调用新函数:
+
+POST https://server.fmode.cn/api/functions
+Header: Content-Type: application/json
+Header: X-Parse-Application-Id: ncloudmaster
+Body: {"id":"THXAOGKiPK","token":"<FMODE_SESSION_TOKEN>","params":{
+  "action":"registerFile",
+  "threadRoot":"<第 1 步取到的 thread_id>",
+  "agentId":"agent-node-lj-test",
+  "roomId":"<HERMES_SESSION_CHAT_ID>",
+  "name":"<原始文件名>",
+  "url":"<https://s3.fmode.cn/...>",
+  "key":"<云盘对象键>",
+  "mimeType":"<类型>","size":<字节数>,
+  "uploadedBy":"agent",
+  "source":"<容器内原始路径>"}}
+
+返回 code=200 且带 id 算成功;duplicated:true 也算成功。
+
+第 3 步 · 不动的东西
+- lfYlgU7SkK 的老调用(report/enqueueArtifact/pendingArtifacts/ackArtifact)保持不动,两套并存
+- 不写 <!--fmode:files-->
+- 正文不写 /opt/data/ 本地路径
+- 不动 Matrix/飞书配置
+
+== 验收 ==
+1. 第 1 步的函数代码
+2. 用这条命令证明能取到:对一个有 thread 的 session_id 跑一次,贴输出
+3. 真实回合里 push 脚本 stdout 全文
+4. 我这边 curl listBySession 能查到你登记的文件
+
+== 纪律 ==
+失败贴原始报错;不要伪造成功;要重启 gateway 先 ps 打印 cmdline 由人决定。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先回执,然后一步一步做。
+
+**收到。** 先读现有 hook 脚本,看清 report 调用点,再动手。这一步只读不改。
+```
+
+### 2【prose】
+
+```text
+```
+sqlite3 /opt/data/state.db ".schema s...
+```
+📖 Reading fmode-deliverable-hook.py
+```
+
+### 3【prose】
+
+```text
+**第 1 步完成。** 先看下 sessions 表的真实数据形态(thread_id 有没有值),再写函数。
+```
+
+### 4【prose】
+
+```text
+* ```
+sqlite3 /opt/data/state.db ".schema s...
+```
+📖 Reading fmode-deliverable-hook.py
+💻 terminal
+```
+python3 -c " ...
+```
+```
+python3 -c " ...
+```
+```
+
+### 5【prose】
+
+```text
+```
+python3 -c " ...
+```
+```
+
+### 6【prose】
+
+```text
+当前这条会话 `20261110_162840_aad60df4` 的 thread_id 为 None,属于 144 条会话中 39 条没有 thread 的情况。接下来找一个有 thread 的 session 做验证。
+```
+
+### 7【prose】
+
+```text
+现在写第 1 步的函数,并接到 hook 脚本里。
+```
+
+### 8【prose】
+
+```text
+**第 1 步:函数代码**(`_thread_root`,已写进 `/opt/data/scripts/fmode-deliverable-hook.py` 第 332-358 行):
+
+```python
+STATE_DB = HOME / "state.db"
+
+
+def _thread_root(sid: str | None = None) -> str | None:
+    """取本会话的 threadRoot。
+
+    HERMES_SESSION_THREAD_ID 在 hook 子进程里拿不到(父进程没导出这个 ContextVar),
+    所以退回读 state.db 的 sessions.thread_id —— 已实测 105/105 命中。
+    取不到一律返回 None(调用方据此跳过登记,绝不瞎编一个根)。
+    """
+    sid = sid or os.environ.get("HERMES_SESSION_ID")
+    if not sid:
+        return None
+    try:
+        import sqlite3
+        con = sqlite3.connect("file:%s?mode=ro" % STATE_DB, uri=True, timeout=5)
+        try:
+            row = con.execute("SELECT thread_id FROM sessions WHERE id=?", (sid,)).fetchone()
+        finally:
+            con.close()
+    except Exception as e:
+        log("THREAD-ROOT-FAIL sid=%s %s" % (sid, e))
+        return None
+    if not row or not row[0]:
+        return None
+    return str(row[0])
+```
+
+**验收点 2** —— 对 4 个 session 实跑(含 1 个无 thread 的当前会话作反例):
+```
+
+### 9【prose】
+
+```text
+**验收点 2 通过。** 两个有 thread 的 session 都拿到了真实值;当前会话(thread_id 本来就是 NULL)和不存在的 session 都正确返回 None。
+
+第 2 步:改登记调用。先确认新云函数 THXAOGKiPK 的参数契约,再动 hook 里的 report 调用点。
+```
+
+### 10【prose】
+
+```text
+```
+ls /opt/data/scripts/ | grep -i -E "p...
+```
+```
+
+### 11【prose】
+
+```text
+先验证新云函数 THXAOGKiPK 的 `registerFile` 接口,并确认参数结构,然后我来决定调用点怎么写。
+```
+
+### 12【prose】
+
+```text
+THXAOGKiPK 已经正常工作,`listBySession` 能返回真实数据,字段名也确认了:`storageKey` 对应的就是任务书里说的「云盘对象键」。
+
+接下来我改 hook 中的登记调用。查一下现在的调用点上下游:
+```
+
+### 13【prose】
+
+```text
+现在动手:先加一个打新云函数的函数,再在 report 交付点加上 threadRoot 闸门和新登记调用。
+```
+
+### 14【prose】
+
+```text
+接下来在 `upload_and_register` 里接入:先取 threadRoot,取不到就跳过登记。
+```
+
+### 15【prose】
+
+```text
+现在在 report 成功、PUT 成功、回读成功后,加上对新 hub 的登记。
+```
+
+### 16【prose】
+
+```text
+**第 2 步改动完成。** 但有个问题要先说明:当前会话 thread_id 是 NULL,会走 SKIP 分支,这样没法验证真实登记。我先用一个有 thread 的 session 模拟一次完整回合来验证,再处理真实回合。
+```
+
+### 17【prose】
+
+```text
+现在在 /tmp 下造一个临时验证文件并手动触发完整链路,用有 thread 的 session 环境变量:
+```
+
+### 18【prose】
+
+```text
+```
+cd /opt/data && HERMES_SESSION_ID='20...
+```
+```
+
+### 19【prose】
+
+```text
+手上这事比较磨,3 分钟了。 — iteration 18/500, waiting for provider response (streaming)
+```
+
+### 20【prose】
+
+```text
+**新云函数登记成功。** 现在回读验证——用 listBySession 查这个 threadRoot:
+```
+
+### 21【prose】
+
+```text
+```
+cd /tmp && python3 -c " ...
+```
+```
+
+### 22【prose】
+
+```text
+**回读确认:`4pA5vi4s4q` / `hub-verify.md` / uploadedBy=agent / 53 字节** —— 正是我刚登记的记录,你那边 `curl listBySession` 能查到。
+
+现在验证反面:当前会话(无 thread)应当输出 SKIP 且不登记。
+```
+
+### 23【prose】
+
+```text
+```
+cd /opt/data && HERMES_SESSION_ID='20...
+```
+```
+
+### 24【prose】
+
+```text
+SKIP 分支验证通过:打印 `SKIP register: no threadRoot`,返回空,没有创建任何记录。
+
+现在看第 3 步:确认真实回合确实走这条路径。先确认这个房间的会话是否会拿到 thread_id。当前会话的 thread_id 是 NULL,先检查一下是不是因为 `MATRIX_AUTO_THREAD` 的配置。
+```
+
+### 25【prose】
+
+```text
+```
+cd /opt/data && python3 -c " ...
+```
+```
+
+### 26【prose】
+
+```text
+这个 DM 房间的会话从来没有 thread_id。因为 `/opt/data/state.db` 里 thread_id 只有在**真实 thread 消息**(带 `m.relates_to rel_type:m.thread`)时才会写入,而这个房间是普通 DM,没有 thread 根。
+
+**这是验收点 3、4 无法用当前这条消息直接完成的客观前提。** 我不会伪造,先如实报告当前状态,再告诉你需要什么条件。
+```
+
+### 27【prose】
+
+```text
+```
+cd /opt/data && grep -nE "MATRIX_AUTO...
+```
+```
+
+### 28【prose】
+
+```text
+我在这个 DM 房间拿不到 thread_id,所以第三条验收(真实回合的 push stdout)现在没法直接跑。
+
+**已完成的(有实跑输出为证):**
+
+**第 1 步 · 取 threadRoot 的函数** —— 已写进 `/opt/data/scripts/fmode-deliverable-hook.py`(`_thread_root`,第 332-358 行):
+
+```python
+STATE_DB = HOME / "state.db"
+
+def _thread_root(sid: str | None = None) -> str | None:
+    sid = sid or os.environ.get("HERMES_SESSION_ID")
+    if not sid:
+        return None
+    try:
+        import sqlite3
+        con = sqlite3.connect("file:%s?mode=ro" % STATE_DB, uri=True, timeout=5)
+        try:
+            row = con.execute("SELECT thread_id FROM sessions WHERE id=?", (sid,)).fetchone()
+        finally:
+            con.close()
+    except Exception as e:
+        log("THREAD-ROOT-FAIL sid=%s %s" % (sid, e))
+        return None
+    if not row or not row[0]:
+        return None
+    return str(row[0])
+```
+
+**验收 2 · 实跑取到 thread(原文):**
+```
+'20261110_002750_9f269dae' -> '$approot_mw9h5oi6_m96imj7x:fmode.cn'
+'20261110_135539_19a14dbd' -> '$approot_mwaa0ov1_k24sn48h:fmode.cn'
+'20261110_162840_aad60df4' -> None      ← 当前会话,state.db 里 thread_id 本来就是 NULL
+'no_such_session'           -> None
+```
+
+**第 2 步 · 登记调用已切到 THXAOGKiPK**,报告点加了闸门:取不到 threadRoot 就 `print("SKIP register: no threadRoot")` 并返回,不写脏记录;取到则 PUT+回读成功后调 `registerFile`,带齐 threadRoot / agentId / roomId / name / url / key / mimeType / size / uploadedBy=agent / source。老 `lfYlgU7SkK` 的 report/enqueueArtifact/pendingArtifacts/ackArtifact 全部原样并存,`<!--fmode:files-->` 没写,正文不输出本地路径,Matrix/飞书配置没动。
+
+**验收 4 · 你 curl listBySession 现在就能查到**(我用 threadRoot `$approot_mw9h5oi6_m96imj7x:fmode.cn` 实跑登记了一条,并回读验证):
+
+```
+count: 2
+ - 4pA5vi4s4q  agent-node-lj-test  hub-verify.md  agent  53      ← 本次新登记
+ - HmeAvoDKkv  agent-node-lj-test  m-composer-...md  user  33   (原有)
+```
+
+反例也跑过:无 thread 的 session 输出 `SKIP register: no threadRoot`,返回 `[]`,没建任何记录。
+
+**验收 3 卡在哪(不伪造):**
+`20261110_162840_aad60df4` 是这个 DM 房间唯一的 session,`thread_id = None`;这个房间历史上 **0 条**有 thread 的 session。state.db 的 thread_id 只在消息带 `m.relates_to{rel_type:m.thread}` 时才有值,普通 DM 消息不会产生。
+
+所以真实回合的 push stdout 我需要在**一个有 thread 根的 Matrix 房间/线程**里触发才能拿到。两个可选路径,你定:
+
+1. 你在任意房间发一条**带 thread 的**消息给我并让我写个交付物 → 我把 push 脚本 stdout 全文贴给你;
+2. 或者你直接告诉我要绑哪个 threadRoot,我用那个 session_id 环境跑一次真实回合。
+
+要重启 gateway 才能生效吗——hook 是每次调用时 exec 脚本,**改完即生效,不需要重启**。你要是怀疑有缓存,我可以 `ps` 打印 gateway cmdline 交给你决定。
+```
+
+### 29【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls -la /opt/data/scripts/fmode-delive... ````
+- `🔧 Editing /opt/data/scripts/fmode-deliverable-h...`
+- `💻 terminal ``` cd /opt/data && python3 -c " ... ````
+- `📖 Reading fmode-artifact-push.py`
+- `* 📖 Reading fmode-artifact-push.py 💻 terminal ``` cd /tmp && cat > temp-probe-hub.py <<... ````
+- `📖 Reading fmode-deliverable-hook.py L440-509`
+- `🔧 Editing /opt/data/scripts/fmode-deliverable-h...`
+- `🔧 Editing /opt/data/scripts/fmode-deliverable-h...`
+- `📖 Reading fmode-deliverable-hook.py L580-639`
+- `📖 Reading fmode-deliverable-hook.py L639-663`
+- `* 📖 Reading fmode-deliverable-hook.py L639-663 🔧 Editing /opt/data/scripts/fmode-deliverable-h...`
+- `* 📖 Reading fmode-deliverable-hook.py L639-663 🔧 Editing /opt/data/scripts/fmode-deliverable-h... 💻 terminal ``` cd /tmp && mkdir -p temp-hubtest && p... ````

+ 427 - 0
docs/03-interface/容器方案-如何可靠拿到threadRoot-20261110.md

@@ -0,0 +1,427 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T08:28:18.302Z
+- 房间:`!SQ9mDELRQ_RijLXpTtM0nfFSgfJYC351JBGmOse6qH4`(临时房间,可保留查阅)
+- 消息统计:正文 28 段 / 工具过程 3 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+只读,不要产出文件、不要调 Report。请把「怎么拿到 thread_id」这个问题彻底解决掉。
+
+你已证明:adapter 层 thread_id 正确、state.db 的 sessions 表里 thread_id 有值、但环境变量 HERMES_SESSION_THREAD_ID 为空(疑似 set_session_vars 位置参数错位)。
+
+Q1: 继续读 session_context.py L115 起 set_session_vars 函数体,把错位点锁死。贴出 _SESSION_VARS 元组和 set_session_vars 的完整定义。
+Q2: 更重要的 —— hook 进程里怎么可靠地拿到当前回合的 thread_id?请实测这三条哪条可行,贴命令和真实输出:
+  a) 用 HERMES_SESSION_ID 去 state.db 的 sessions 表查 thread_id
+  b) 用 HERMES_SESSION_KEY(里面已经含 thread 段)按冒号切分取第 5 段
+  c) 其他你认为可靠的方式
+Q3: 给出你推荐的方案,以及一行可直接执行的验证命令。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先定位 Hermes 的 session_context.py 源码,确认它实际所在的位置,再挨项实测。
+```
+
+### 2【prose】
+
+```text
+```
+ls /opt/data/state.db 2>/dev/null && ...
+```
+```
+
+### 3【prose】
+
+```text
+* ```
+ls /opt/data/state.db 2>/dev/null && ...
+```
+```
+grep -n "_SESSION_VARS\|def set_sessi...
+```
+```
+
+### 4【prose】
+
+```text
+* ```
+ls /opt/data/state.db 2>/dev/null && ...
+```
+```
+grep -n "_SESSION_VARS\|def set_sessi...
+```
+```
+env | grep -i "^HERMES_" | sort; echo...
+```
+📖 Reading session_context.py L25-169
+```
+
+### 5【prose】
+
+```text
+`set_session_vars` 本身没有错位:`_SESSION_VARS` 的 19 个元素与 `values` 的 19 个元素一一对应。接下来要检查的是调用方,看是不是调用时按位置传参出的问题。
+```
+
+### 6【prose】
+
+```text
+* ```
+ls /opt/data/state.db 2>/dev/null && ...
+```
+```
+grep -n "_SESSION_VARS\|def set_sessi...
+```
+```
+env | grep -i "^HERMES_" | sort; echo...
+```
+📖 Reading session_context.py L25-169
+💻 terminal
+```
+grep -rn "set_session_vars(" /opt/her...
+```
+```
+
+### 7【prose】
+
+```text
+```
+cd /opt/data && sqlite3 state.db ".sc...
+```
+📖 Reading run.py L4155-4199
+```
+
+### 8【prose】
+
+```text
+调用方全部使用 kwarg,`thread_id` 也确实传了。那么真凶可能就是:`context.source.thread_id` 本身在这里为空。下面逐条实测。
+```
+
+### 9【prose】
+
+```text
+* ```
+cd /opt/data && sqlite3 state.db ".sc...
+```
+📖 Reading run.py L4155-4199
+💻 terminal
+```
+cd /opt/data && python3 -c " ...
+```
+```
+cd /opt/data && python3 -c " ...
+```
+```
+
+### 10【prose】
+
+```text
+关键发现:state.db 中该会话的 `thread_id` 是 **NULL**,`session_key` 也只有 5 段,第 5 段是 **chat_id**,并不是 thread。这与“adapter 层 thread_id 正确”的假设相矛盾,我先把这个矛盾核实清楚。
+```
+
+### 11【prose】
+
+```text
+```
+grep -n "def build_session_key" -A 45...
+```
+```
+
+### 12【prose】
+
+```text
+state.db 里有 105 个会话的 `thread_id` 并非为空,但我当前会话是 NULL。另外要注意,这些非空的其实是 `$approot_...` 形式的 Matrix threadevent 回执 bot。我需要继续查清我当前会话为什么是 NULL。
+```
+
+### 13【prose】
+
+```text
+```
+cd /opt/data && python3 -c " ...
+```
+```
+cd /opt/data && python3 -c " ...
+```
+```
+
+### 14【prose】
+
+```text
+```
+ls /opt/hermes/gateway/platforms/ | h...
+```
+```
+find /opt/data/plugins /opt/hermes/pl...
+```
+```
+
+### 15【prose】
+
+```text
+关键发现:**thread_id 有时是整条消息原文**,对应那条“生成 8 个文件”的消息。这说明有某段代码把消息正文当成 thread_id 传进来了。Matrix 适配器是插件形式,我先定位它。
+```
+
+### 16【prose】
+
+```text
+确认了一处重大 bug:2026-10-31 那条会话把**整条消息正文写进了 thread_id**。现在去抓 Matrix 插件里 thread_id 的来源。
+```
+
+### 17【prose】
+
+```text
+```
+cd /opt/data && python3 -c " ...
+```
+```
+ls -R /opt/hermes/plugins/platforms/m...
+```
+```
+
+### 18【prose】
+
+```text
+我在插件 adapter 的 L2010 和 L2054-2068 找到了线程合成的逻辑。接下来读完这段代码。
+```
+
+### 19【prose】
+
+```text
+现在我来实测 adapter 当前实际生效的开关值,这是判断为什么本会话 thread_id 为空的关键。
+```
+
+### 20【prose】
+
+```text
+这些开关的默认值是:在 DM 场景下 `DM_AUTO_THREAD=false` → 不会合成 thread → `thread_id=None`。但这与“105 个会话的非空 thread_id”并不矛盾:那些会话都是真实带 `m.thread` 的。现在做 Q2 的三条实测。
+```
+
+### 21【prose】
+
+```text
+```
+ls -la /opt/data/plugins/ 2>/dev/null...
+```
+```
+cd /opt/data && echo "=== 实测 a: HERME...
+```
+```
+
+### 22【prose】
+
+```text
+```
+cd /opt/data && echo "=== 实测 b: SESSI...
+```
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 23【prose】
+
+```text
+b 方案也不成立,因为第 5 段其实是 chat_id。接下来需要验证:**当会话里确实有 thread 时,这条路径能不能拿到 thread_id**。这才是真正要解决的问题。
+```
+
+### 24【prose】
+
+```text
+**决定性结果**:a 方案可靠,b 方案不可靠。带冒号的 thread_id(`$cfgfix_...:fmode.cn`)把 key 撑成 7 段,按位置取第 5 段或末段都会出错。我再验证 a 方案在本容器里的稳定性和边界情况。
+```
+
+### 25【prose】
+
+```text
+```
+cd /opt/hermes && grep -rn "get_sessi...
+```
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 26【prose】
+
+```text
+注意到关键点:`get_session_env` **优先读取 ContextVar,如果它是 `_UNSET` 才回落到 os.environ**。所以进程内读到的空值,正是 ContextVar 被绑定成 `""`(`thread_id` 为 None)造成的,并非错位。最后再验证一下 shell 子进程收到的环境。
+```
+
+### 27【prose】
+
+```text
+```
+cd /opt/hermes && sed -n '170,195p' g...
+```
+```
+cd /tmp && python3 - <<'EOF' ...
+```
+```
+
+### 28【prose】
+
+```text
+三条都实测完了。我先把门锁死:结论是**所谓“位置参数错位”并不存在**。
+
+## Q1:`set_session_vars` 完整定义(`/opt/hermes/gateway/session_context.py` L34-48、L115-144)
+
+**L34-48 `_SESSION_VARS` 元组,共 19 个:**
+```python
+_SESSION_VARS = (
+    _SESSION_PLATFORM, _SESSION_SOURCE, _SESSION_CHAT_ID, _SESSION_CHAT_TYPE,
+    _SESSION_CHAT_NAME, _SESSION_THREAD_ID, _SESSION_USER_ID, _SESSION_USER_ID_ALT,
+    _SESSION_USER_NAME, _SESSION_SCOPE_ID, _SESSION_KEY, _SESSION_ID,
+    _SESSION_UI_SESSION_ID, _SESSION_MESSAGE_ID, _SESSION_PROFILE,
+    _BROWSER_CONTROL_PRINCIPAL, _BROWSER_CONTROL_TRANSPORT_FAMILY, _CRON_SESSION, _SESSION_PARENT_CHAT_ID,
+) = tuple(ContextVar(name, default=_UNSET) for name in (
+    "HERMES_SESSION_PLATFORM", "HERMES_SESSION_SOURCE", "HERMES_SESSION_CHAT_ID",
+    "HERMES_SESSION_CHAT_TYPE", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID",
+    "HERMES_SESSION_USER_ID", "HERMES_SESSION_USER_ID_ALT", "HERMES_SESSION_USER_NAME",
+    "HERMES_SESSION_SCOPE_ID", "HERMES_SESSION_KEY", "HERMES_SESSION_ID",
+    "HERMES_UI_SESSION_ID", "HERMES_SESSION_MESSAGE_ID", "HERMES_SESSION_PROFILE",
+    "HERMES_BROWSER_CONTROL_PRINCIPAL", "HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY",
+    "HERMES_CRON_SESSION", "HERMES_SESSION_PARENT_CHAT_ID",
+))
+```
+
+**L115-144 `values` 部分:**
+```python
+    values = (
+        platform, source, chat_id, chat_type, chat_name, thread_id, user_id, user_id_alt,
+        user_name, scope_id, session_key, session_id, ui_session_id, message_id, profile,
+        browser_control_principal, browser_control_transport_family, cron_session, parent_chat_id,
+    )
+    tokens = [var.set(value) for var, value in zip(_SESSION_VARS, values)]
+```
+
+**错位点:不存在。** `_SESSION_VARS` 的第 6 位 `_SESSION_THREAD_ID` 对 `values` 第 6 位 `thread_id`,共 19 对 19,逐位对得上。注释 L27 也写明 “tuple ORDER is the positional order of ``values``”。
+
+**真正的原因(回读证据):**
+- 调用方 `gateway/run.py:4170-4185` 用的是 **kwarg**,不是位置参数:`thread_id=str(context.source.thread_id) if context.source.thread_id else ""`。
+- 本会话 `thread_id` 实为 `None`,所以被写成 `""` —— 这是**正确的空值透传**,不是错位。
+
+`thread_id` 为 `None` 的根源在 adapter,`plugins/platforms/matrix/adapter.py:2054-2061`:
+```python
+if not thread_id:
+    if is_dm:
+        synthetic = (self._dm_mention_threads and is_mentioned) or self._dm_auto_thread
+    ...
+    if synthetic:
+        thread_id = event_id
+```
+而 L869/L871 默认 `MATRIX_DM_AUTO_THREAD=false`、`MATRIX_DM_MENTION_THREADS=false`,且消息无 `m.relates_to{rel_type:m.thread}`,所以在 DM 里不合成 thread,最终为 `None`。
+
+另外,`get_session_env`(L173-179)的语义是 **ContextVar 优先,`_UNSET` 才回落 os.environ**。绑定成 `""` 时它直接返回 `""`,不会去读环境;这只说明“本回合确实没有 thread”,而非“绑定丢了”。
+
+---
+
+## Q2:hook 进程里取 thread_id,三条实测
+
+### a) 用 `HERMES_SESSION_ID` 查 state.db 的 `sessions.thread_id` —— 可行
+
+```bash
+python3 -c "
+import sqlite3,os
+sid=os.environ['HERMES_SESSION_ID']
+c=sqlite3.connect('file:/opt/data/state.db?mode=ro',uri=True)
+print(c.execute('SELECT thread_id FROM sessions WHERE id=?',(sid,)).fetchone()[0])
+"
+```
+真实输出(本会话):
+```
+HERMES_SESSION_ID = 20261110_162522_7143da7d
+db row            = ('20261110_162522_7143da7d', None, '!SQ9mDELRQ_RijLXpTtM0nfFSgfJYC351JBGmOse6qH4', 'dm')
+thread_id repr    = None
+```
+在确实有 thread 的会话上复测:
+```
+样本 session_id = 20261108_144500_f5924792
+a) 按 SESSION_ID 查 → '$cfgfix_1794120295203_egx335:fmode.cn'
+```
+覆盖率全库扫描:`总会话 143,有 thread_id 105`,`a 方案命中率: 105/105`。**结论:只要会话有 thread,就一定查得到;本会话是 None,因为本来就没有 thread —— 语义正确。**
+
+### b) 用 `HERMES_SESSION_KEY` 按冒号切分取第 5 段 —— **不可行,会错**
+
+```
+KEY=agent:...6qH4
+段数 = 5 -> ['agent', 'main', 'matrix', 'dm', '!SQ9mDELRQ_RijLXpTtM0nfFSgfJYC351JBGmOse6qH4']
+第5段 = !SQ9mDELRQ_RijLXpTtM0nfFSgfJYC351JBGmOse6qH4   ← 这是 chat_id,不是 thread
+```
+而且 `build_session_key`(`gateway/session.py:654-695`)的布局是 `<ns>:<platform>:<chat_type>[:scope_id][:chat_id][:thread_id][:user]`,**段位会浮动**,thread 根本不在第 5 段。反例(thread 里带冒号,直接被切碎):
+```
+db session_key=agent:...e.cn
+b) KEY 段数 = 7 末段 = 'fmode.cn' -> 与 db thread_id 一致? False
+```
+**结论:b 不可用。** 位置切分既取错段、又被含冒号的 event_id 打碎。弃。
+
+### c) 更可靠的方式:直接读 ContextVar(`get_session_env`)
+
+hook 若在 gateway 进程内跑(非独立子进程),ContextVar 才是权威源,且不受“绑定成空串”的歧义影响:
+```python
+from gateway.session_context import _VAR_MAP
+v = _VAR_MAP["HERMES_SESSION_THREAD_ID"].get()   # _UNSET = 从未绑定;"" = 明确空
+```
+但 shell hook 是子进程,拿不到父进程的 ContextVar,只能靠环境或 DB 回落。实测父进程环境:
+```
+pid 134 (hermes): HERMES_SESSION_THREAD_ID=<not set>
+pid 18767 (hermes): HERMES_SESSION_THREAD_ID=<not set>
+pid 20655 (python): HERMES_SESSION_THREAD_ID=<not set>
+```
+**父进程压根没导出这个变量**(它是 ContextVar,不是 os.environ 常驻项),所以子进程里再怎么 `echo` 也只会是空。
+
+---
+
+## Q3:推荐方案 + 一行验证命令
+
+**推荐:以 a 为准,c 作进程内优先,b 废弃。**
+
+```python
+def get_thread_id() -> str | None:
+    # 1) 进程内:ContextVar 权威(能区分 _UNSET 与 "")
+    try:
+        from gateway.session_context import _VAR_MAP, _UNSET
+        v = _VAR_MAP["HERMES_SESSION_THREAD_ID"].get()
+        if v is not _UNSET:
+            return v or None
+    except Exception:
+        pass
+    # 2) 环境变量(少数路径会导出)
+    if t := os.environ.get("HERMES_SESSION_THREAD_ID"):
+        return t
+    # 3) 落库回落:用 SESSION_ID 查 sessions.thread_id
+    sid = os.environ.get("HERMES_SESSION_ID", "")
+    if sid:
+        import sqlite3
+        c = sqlite3.connect(f"file:{os.path.expanduser('~/.hermes/state.db')}?mode=ro", uri=True)
+        row = c.execute("SELECT thread_id FROM sessions WHERE id=?", (sid,)).fetchone()
+        return (row[0] or None) if row else None
+    return None
+```
+注意 DB 路径用 `$HERMES_HOME/state.db`(本机 `/opt/data/state.db`),不要照抄 `~/.hermes`。另外**绝不从 SESSION_KEY 切片取 thread**。
+
+**一行验证命令(直接贴终端即可,只读):**
+```bash
+python3 -c "import sqlite3,os;sid=os.environ.get('HERMES_SESSION_ID','');p=os.path.join(os.environ.get('HERMES_HOME','/opt/data'),'state.db');print('sid=',sid,'thread=',sqlite3.connect('file:'+p+'?mode=ro',uri=True).execute('select thread_id from sessions where id=?',(sid,)).fetchone())"
+```
+
+**一句话总结:** `set_session_vars` 没错位,`HERMES_SESSION_THREAD_ID` 是该用 ContextVar 读的瞬时值、不该指望子进程环境;本会话取到空是因为 DM 未合成 thread(`MATRIX_DM_AUTO_THREAD=false`),这是正确行为。要拿到值就查库(105/105 命中),别切 key。
+```
+
+### 29【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` python3 -c "import hermes,os;print(os... ````
+- `📖 Reading adapter.py L2005-2069`
+- `💻 terminal ``` grep -n "MATRIX_AUTO_THREAD\|MATRIX_S... ``` ``` cd /opt/hermes && python3 - <<'EOF' ... ````

+ 308 - 0
docs/03-interface/容器核查-真实会话thread值-20261110.md

@@ -0,0 +1,308 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T08:25:01.714Z
+- 房间:`!Cq1D3hMCL8I7ZhQgTKiSyrgqM40d_UTvQH6c7OAZ4Ug`(临时房间,可保留查阅)
+- 消息统计:正文 24 段 / 工具过程 4 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+只读,不要产出文件、不要调 Report。
+
+你刚才说 HERMES_SESSION_THREAD_ID 为空。但我刚在 App 真实会话里给你发了一条消息,会话 id 是 $approot_mw9h5oi6_m96imj7x:fmode.cn,App 发出去的 content 确认带了:
+  "m.relates_to": {"rel_type":"m.thread","event_id":"$approot_mw9h5oi6_m96imj7x:fmode.cn","is_falling_back":true}
+
+现在请查你刚处理的那条消息(body 是「请只回一句话:收到。不要调用任何工具。」):
+Q1: 这一回合的 HERMES_SESSION_THREAD_ID 是什么值?贴 env 原文。
+Q2: 如果还是空,去看 adapter.py L2015 那行,在这条消息上 thread_id 被赋成了什么?贴出你实际读到的 relates_to 结构。
+Q3: L2054-2061 的「合成 thread_id = event_id」分支在这条消息上有没有触发?
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先确认这条消息实际处理时拿到的 thread 环境变量,然后去读 adapter.py 对应代码行。
+```
+
+### 2【prose】
+
+```text
+确认:Q1 的 env 原文就是空的。下面去看 adapter.py 的实际代码行和这条消息的原始事件。
+```
+
+### 3【prose】
+
+```text
+```
+ls -la /opt/data/logs/ 2>/dev/null | ...
+```
+```
+find /opt/hermes -name 'adapter.py' -...
+```
+```
+
+### 4【prose】
+
+```text
+```
+grep -n 'HERMES_SESSION_THREAD_ID' /o...
+```
+📖 Reading adapter.py L1985-2094
+```
+
+### 5【prose】
+
+```text
+找到那条目标消息了(16:22:09,room `!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU`)。现在需要看这条消息的原始事件结构——adapter 有没有把 `relates_to` 打日志,以及 App 那条到底带的什么。
+```
+
+### 6【prose】
+
+```text
+* ```
+grep -n 'HERMES_SESSION_THREAD_ID' /o...
+```
+📖 Reading adapter.py L1985-2094
+💻 terminal
+```
+grep -n '请只回一句话' /opt/data/logs/gatew...
+```
+```
+grep -n 'miyB1DxRE5E9Aj5d7lE1wcgPWXWx...
+```
+```
+
+### 7【prose】
+
+```text
+关键线索:session key 是 `agent:main:matrix:dm:!miyB...:$approot_mw9h5oi6_m96imj7x:fmode.cn` —— thread 段**确实存在**,是 App 的 event_id。但会话 env 里 `HERMES_SESSION_THREAD_ID` 却是空的。先去配置和代码里核实这两条路。
+```
+
+### 8【prose】
+
+```text
+```
+grep -n 'MATRIX_AUTO_THREAD\|MATRIX_S...
+```
+```
+for f in /opt/data/.env /opt/data/con...
+```
+```
+
+### 9【prose】
+
+```text
+配置里找不到 `.env` 和 `config.yaml` 这两个文件,`gateway` 进程环境里也没有 `MATRIX_*`。我确认真实路径,再看线程开关的初始化代码。
+```
+
+### 10【prose】
+
+```text
+```
+grep -n 'self._auto_thread\s*=\|self....
+```
+```
+echo "=== /opt/data 顶层 ==="; ls -a /o...
+```
+```
+
+### 11【prose】
+
+```text
+配置读到了。现在查实际生效的 matrix 配置段,以及 thread_id 从 adapter 传到会话 env 的那一链路。
+```
+
+### 12【prose】
+
+```text
+配置文件和 .env 都没有覆盖 matrix 的线程开关。接下来看 thread_id 如何写入 session env,同时确认那条消息的真实事件内容。
+```
+
+### 13【prose】
+
+```text
+```
+grep -n -i 'auto_thread\|session_scop...
+```
+```
+grep -rn 'HERMES_SESSION_THREAD_ID' /...
+```
+```
+
+### 14【prose】
+
+```text
+```
+grep -n 'session_thread_id\|thread_id...
+```
+📖 Reading session_context.py L25-84
+```
+
+### 15【prose】
+
+```text
+关键点找到了:`HERMES_SESSION_THREAD_ID` 是 ContextVar,同时写入 os.environ。现在看这条消息的原始事件到底带什么,直接从 Matrix 服务器回放取真实事件。
+```
+
+### 16【prose】
+
+```text
+* ```
+grep -n 'session_thread_id\|thread_id...
+```
+📖 Reading session_context.py L25-84
+💻 terminal
+```
+grep -rn 'HERMES_SESSION_THREAD_ID\|s...
+```
+```
+grep -rn 'set_session_vars' /opt/herm...
+```
+```
+
+### 17【prose】
+
+```text
+现在直接用 bot 的 access token 从 Matrix 服务器回读那条事件的原始 content——这是唯一能确证 App 实际发了什么的办法。
+```
+
+### 18【prose】
+
+```text
+```
+grep -n 'MATRIX_\|homeserver\|user_id...
+```
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 19【prose】
+
+```text
+真实事件拿到了,App 那条**确实带了** `rel_type: m.thread` + `event_id: $approot_mw9h5oi6_m96imj7x:fmode.cn`。那就说明 L2015 应该会赋上值。继续追 thread_id 到 env 的传递链路。
+```
+
+### 20【prose】
+
+```text
+现在调用链已经完整了。我去看会话实际保存的记录,验证那一回合 thread 是否真的被带上。
+```
+
+### 21【prose】
+
+```text
+全部证据都在了。最后确认一下那条消息的 session_key 是怎么构出来的,以及 thread 是否真的进了 session。
+```
+
+### 22【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 23【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 24【prose】
+
+```text
+全部证据已拿到。三问都有确定答案,且互相印证。
+
+---
+
+**Q1:这一回合的 HERMES_SESSION_THREAD_ID 是什么值?**
+
+我当时查到的 env 原文(`env | grep ^HERMES_SESSION` 输出,逐字复制):
+
+```
+HERMES_SESSION_THREAD_ID=
+```
+
+值是**空字符串**,不是缺失。
+
+但注意:**这条消息并不是「请只回一句话:收到。」那一回合**。那条消息(`$ga940L7f...`,16:22:09)我是在别的会话里处理的,当时 env 也是空。而现在这个回合(你这条长消息)同一个变量同样是空。
+
+**Q2:adapter.py L2015 上 thread_id 被赋成了什么?我实测的 relates_to 结构**
+
+我从 homeserver 直接回读了那条事件的原始 content(`/_matrix/client/v3/rooms/{room}/messages`,bot token 直读),原始输出:
+
+```
+EVENT_ID: $ga940L7f5B3lemLJomHTLtwDlOZwiDz__gDNNmlb-jU
+  sender: @xhvrjtmwqh:fmode.cn
+  msgtype: m.text
+  body: '请只回一句话:收到。不要调用任何工具。'
+  relates_to: {"event_id": "$approot_mw9h5oi6_m96imj7x:fmode.cn", "is_falling_back": true, "rel_type": "m.thread"}
+```
+
+**你带的结构是真的,服务器上就是这么存的。** 所以按 L2015:
+
+```python
+thread_id = relates_to.get("event_id") if relates_to.get("rel_type") == "m.thread" else None
+```
+
+`rel_type` 确实是 `"m.thread"`,`event_id` 确实是 `$approot_mw9h5oi6_m96imj7x:fmode.cn` → **L2015 把 thread_id 正确赋成了 `$approot_mw9h5oi6_m96imj7x:fmode.cn`,不为空。**
+
+**Q3:L2054-2061 的合成分支有没有触发?**
+
+**没有触发。** L2054 的条件是 `if not thread_id:`,而 thread_id 在 L2015 已拿到真值,直接跳过整个合成分支。合成分支只在消息**不带** `m.relates_to` 时才走。
+
+---
+
+**结论:adapter 层是对的,丢失发生在 adapter 之后。**
+
+三条独立证据链一致指向 thread_id 在 adapter 层是存在的:
+
+1. **session_key 里带着 thread 段**(gateway.log 19556 行原文):
+   `session=agent:main:matrix:dm:!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU:$approot_mw9h5oi6_m96imj7x:fmode.cn`
+   session_key 的第 5 段就是 thread_id(`session.py` L660 格式定义:`<ns>:<platform>:<chat_type>[:<chat_id>][:<thread_id>][:<user>]`)。能出现在 key 里,说明 `source.thread_id` 当时有值。
+
+2. **数据库里持久化正确**(state.db `sessions` 表原文):
+   ```
+   'session_key': 'agent:main:matrix:dm:!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU:$approot_mw9h5oi6_m96imj7x:fmode.cn',
+   'thread_id':  '$approot_mw9h5oi6_m96imj7x:fmode.cn'
+   ```
+   `thread_id` 列有值,不是 NULL 也不是空串。
+
+3. **`Chat` 里也确实多用同一 thread_id 分流**:16:02、16:05、16:13、16:22 四次都是同一个 key,前缀一致 —— 说明 thread 分流正常工作。
+
+所以问题定位在 **`_set_session_env` 这一跳**。`gateway/run.py` L4175:
+
+```python
+thread_id=str(context.source.thread_id) if context.source.thread_id else "",
+```
+
+这句本身没问题(thread_id 有值就该传进去)。值是进入 `set_session_vars` → ContextVar → `os.environ` 的写入环节丢的。`session_context.py` L34-48 里 `_SESSION_THREAD_ID` 是 ContextVar,`set_session_vars` 靠**位置参数 zip** 绑定(L27 注释:「tuple ORDER is the positional order of values in set_session_vars (zipped)」)——**`_SESSION_VARS` 元组顺序与 `set_session_vars` 调用处关键字参数展开成的位置顺序只要错一位,就会把 thread_id 写到别的变量上**。
+
+我这回合看到的现象支持这个判断:`HERMES_SESSION_PARENT_CHAT_ID` 也是空,但按 L2066 `parent_chat_id=room_id if thread_id else None`,thread_id 有值时 parent_chat_id 应该是 room_id,**不该为空**。两个变量同时错位为空 = 典型的 zip 位错特征。
+
+**要精确定位,还需要一步(只读)**:`session_context.py` L115 起的 `set_session_vars` 函数体,看它收到的 `values` 是怎么与 `_SESSION_VARS` 配对的。要我继续读 L115-180 把错位点锁死吗?这仍是纯读,不产文件不调 Report。
+```
+
+### 25【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "--- 当前进程 env 里的 HERMES_SESSION_... ````
+- `📖 Reading adapter.py L850-889 💻 terminal ``` grep -n -A 30 -i 'matrix' /opt/data/c... ````
+- `📖 Reading run.py L4155-4184`
+- `💻 terminal ``` grep -n 'handle_message\|_resolve_mes... ``` ``` grep -n 'approot_mw9h5oi6_m96imj7x' /... ````

+ 339 - 0
docs/03-interface/容器确认-threadRoot可行性-20261110.md

@@ -0,0 +1,339 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T08:18:47.166Z
+- 房间:`!JjkRV15Pu7P_v9mNLIsorh2A12Yq0wPnWjxJ98AsEq0`(临时房间,可保留查阅)
+- 消息统计:正文 26 段 / 工具过程 7 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+只读回答,不产出文件、不调 Report。
+
+现在是设计决策阶段,需要你先确认可行性,再谈实现。
+
+Q1: 一条 m.room.message 事件里带 m.relates_to{rel_type:"m.thread", event_id:"$xxx"},你处理这条消息时能读到这个 event_id 吗?贴出你 adapter 里读取它的代码位置和变量名。
+Q2: post_tool_call hook 启动的进程里,有没有办法拿到上面那个 thread 的 event_id?比如环境变量、状态文件、或者从 session_id 反查。贴出你现在能拿到的全部 session 相关环境变量。
+Q3: 如果 App 在消息 content 里额外加一个字段 {"com.fmode.sessionId": "<会话id>"},你的 adapter 能读到并写进环境变量或状态文件吗?贴出消息 content 的完整结构样例。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先确认这些问题的真实情况,查看 adapter 代码位置和进程环境,只读不改。
+```
+
+### 2【prose】
+
+```text
+```
+python3 -c "import hermes, os; print(...
+```
+```
+python3 -c "import hermes_agent, os; ...
+```
+```
+
+### 3【prose】
+
+```text
+* ```
+python3 -c "import hermes, os; print(...
+```
+```
+python3 -c "import hermes_agent, os; ...
+```
+```
+ps aux | grep -i hermes | grep -v gre...
+```
+```
+
+### 4【prose】
+
+```text
+Hermes 安装在 /opt/hermes,现在定位 Matrix adapter 和 hook 相关代码。
+```
+
+### 5【prose】
+
+```text
+* ```
+python3 -c "import hermes, os; print(...
+```
+```
+python3 -c "import hermes_agent, os; ...
+```
+```
+ps aux | grep -i hermes | grep -v gre...
+```
+```
+ls /opt/hermes/.venv/lib/python*/site...
+```
+```
+
+### 6【prose】
+
+```text
+```
+ls -la /opt/hermes/plugins/platforms/...
+```
+```
+
+### 7【prose】
+
+```text
+Q1 已找到关键代码,现在读取完整上下文。
+```
+
+### 8【prose】
+
+```text
+* ```
+ls -la /opt/hermes/plugins/platforms/...
+```
+🔎 Searching files for relates_to|m\.thread|thread_id
+📖 Reading adapter.py L1960-2084
+```
+
+### 9【prose】
+
+```text
+现在追查 `thread_id` 在向下游传递时最终会写入哪个环境变量(即 setenv 或 os.environ 的位置),以及 session 相关的环境变量。
+```
+
+### 10【prose】
+
+```text
+...
+```
+
+### 11【prose】
+
+```text
+```
+ls /opt/hermes; echo ===; ls /opt/her...
+```
+```
+cd /opt/hermes && ls hooks* 2>/dev/nu...
+```
+```
+
+### 12【prose】
+
+```text
+真正能回答 Q2 的是 hook 子进程实际拿到的环境变量,我现在直接从运行中的 gateway 进程以及 hook 执行代码这两处取证。
+```
+
+### 13【prose】
+
+```text
+现在确认 hook 子进程的环境是怎么构建的,以及 session 相关的信息里有哪些能传下去。
+```
+
+### 14【prose】
+
+```text
+```
+tr '\0' '\n' < /proc/18767/environ | ...
+```
+📖 Reading shell_hooks.py L280-359
+```
+
+### 15【prose】
+
+```text
+关键发现:`_bridge_session_context` 会把 `HERMES_SESSION_*` 的 ContextVars 写进子进程环境。现在确认它具体会写入哪些键,以及 hook payload 里包含哪些字段。
+```
+
+### 16【prose】
+
+```text
+找到了 `gateway/session_context.py` 的 `_VAR_MAP`,这就是全部 session 环境变量的权威定义。
+```
+
+### 17【prose】
+
+```text
+```
+cd /opt/hermes && grep -n "_serialize...
+```
+📖 Reading session_context.py
+```
+
+### 18【prose】
+
+```text
+现在读取 `_SESSION_VARS` 的完整定义(也就是 Q2 需要的完整清单),以及 hook payload 的字段。
+```
+
+### 19【prose】
+
+```text
+关键结论已经全部拿到。最后确认 Q3:`source_content` 是完整 content dict,以及 thread_id/MESSAGE_ID 是怎样写成环境变量的绑定路径(`set_session_vars` 调用点)。
+```
+
+### 20【prose】
+
+```text
+```
+cd /opt/hermes && grep -n "def set_se...
+```
+📖 Reading run.py L4160-4199
+```
+
+### 21【prose】
+
+```text
+全部证据链已闭合。现在做一次实测验证:直接调用 build_subprocess_env 并注入模拟的 session context,确认真实环境变量键名。
+```
+
+### 22【prose】
+
+```text
+* ```
+cd /opt/hermes && grep -n "def set_se...
+```
+📖 Reading run.py L4160-4199
+💻 terminal
+```
+cd /opt/hermes && ./.venv/bin/python ...
+```
+```
+
+### 23【prose】
+
+```text
+实测输出混入了当前进程的真实值,因为 `os.environ` 的默认基值优先。要精确分离,需显式传 base={},再验证。
+```
+
+### 24【prose】
+
+```text
+```
+cd /opt/hermes && ./.venv/bin/python ...
+```
+```
+
+### 25【prose】
+
+```text
+还在推进,3 分钟了,再给我一点时间。 — iteration 18/500, API call #18 completed
+```
+
+### 26【prose】
+
+```text
+三项全部确认可行,且我已用实测验证。以下逐题回答。
+
+## Q1:能读到,且已落到 thread_id 变量
+
+**能读到。** 代码路径在 `/opt/hermes/plugins/platforms/matrix/adapter.py`:
+
+- **L1996** `relates_to = source_content.get("m.relates_to", {})` — 从事件 content 里取出整个 relates_to
+- **L1997** `if relates_to.get("rel_type") == "m.replace": return` — 只跳过编辑,不跳过 thread
+- **L2015**(`_resolve_message_context` 内)**就是取值那一行**:
+  ```python
+  thread_id = relates_to.get("event_id") if relates_to.get("rel_type") == "m.thread" else None
+  ```
+  变量名就是 **`thread_id`**。
+- **L2063-2066** 写进 source:`thread_id=thread_id`,同时 `parent_chat_id=room_id if thread_id else None`、`message_id=event_id`
+- **L2067-2068** `self._threads.mark(thread_id)` 登记该线程,后续同线程消息的 mention 门槛会放行
+
+注意一个边界:**L2054-2061** 在无 m.relates_to 时会合成 `thread_id = event_id`(当前房间 MATRIX_AUTO_THREAD=true,所以你这条消息就是合成的 thread 根)。真实 thread 的 event_id 优先,不会被合成覆盖。
+
+## Q2:能拿到,权威通道是环境变量 `HERMES_SESSION_THREAD_ID`
+
+hook 子进程由 `agent/shell_hooks.py` **L314** 启动,env 用的是 `build_subprocess_env(scrub_secrets=is_multiplex_active())`,它内部调 `_inject_session_context_env`(`tools/environments/local.py` L216),把 session ContextVar 桥接进子进程。
+
+**实测输出来自这次会话当前进程,即通过 /proc 读取真实 gateway 进程的环境:**
+- `HERMES_SESSION_THREAD_ID = ` (空,因为你这个房间是 DM 且本条消息本身是合成线程根;有真实 thread 时此处就是 `$xxx`)
+- `HERMES_SESSION_MESSAGE_ID = $HeoUQcmUGfEBGrxpaXFG6P866wV0t9UcnRD4cjVSUD8`
+
+**完整 session 环境变量清单(共 19 个,来自 `gateway/session_context.py` L34-48 的 `_SESSION_VARS`,逐个实测注入确认):**
+
+| 变量(这里是列表,不是表格) |
+|---|
+- `HERMES_SESSION_PLATFORM`  实测 = `matrix`
+- `HERMES_SESSION_SOURCE`
+- `HERMES_SESSION_CHAT_ID`  实测 = `!JjkRV15Pu7P_v9mNLIsorh2A12Yq0wPnWjxJ98AsEq0`
+- `HERMES_SESSION_CHAT_TYPE`  实测 = `dm`
+- `HERMES_SESSION_CHAT_NAME`  实测 = `ask-1794298537976`
+- `HERMES_SESSION_THREAD_ID`  **← 你要的 thread event_id**
+- `HERMES_SESSION_USER_ID`  实测 = `@xhvrjtmwqh:fmode.cn`
+- `HERMES_SESSION_USER_ID_ALT`
+- `HERMES_SESSION_USER_NAME`  实测 = `xhvrjtmwqh 🏳️‍⚧️`
+- `HERMES_SESSION_SCOPE_ID`
+- `HERMES_SESSION_KEY`  实测 = `agent:main:matrix:dm:!JjkRV15Pu7P_v9mNLIsorh2A12Yq0wPnWjxJ98AsEq0`
+- `HERMES_SESSION_ID`  实测 = `20261110_161543_b381bc8f`
+- `HERMES_UI_SESSION_ID`
+- `HERMES_SESSION_MESSAGE_ID`  实测 = `$HeoUQcmUGfEBGrxpaXFG6P866wV0t9UcnRD4cjVSUD8`
+- `HERMES_SESSION_PROFILE`
+- `HERMES_SESSION_PARENT_CHAT_ID`
+- `HERMES_BROWSER_CONTROL_PRINCIPAL`
+- `HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY`
+- `HERMES_CRON_SESSION`
+
+另外 hook 的** stdin JSON 里也有 `session_id`**(`_payload_fields` L89:`kwargs.get("session_id") or kwargs.get("parent_session_id")`),以及 `extra` 字段装其余 kwargs。这是比环境变量更稳的兜底。
+
+**反查限制(这点很关键):** `session_id` 和 `HERMES_SESSION_KEY` **不能反推出 thread event_id**。原因见 `build_session_key`:当 `MATRIX_SESSION_SCOPE=auto` + `MATRIX_AUTO_THREAD=true` 时每条消息都成为独立线程根,session_key 里**不含 thread 段**(实测上面就是 `dm:!ROOM` 结尾)。所以**只能靠 `HERMES_SESSION_THREAD_ID` 直接读,不能靠反查**。
+
+## Q3:能读到,且已在 content 里、无需改 adapter
+
+**能读到,而且零改动。** 因为 adapter 从未裁剪 content,`source_content` 就是**完整的原始 content dict**(L1988-1995):
+
+```python
+content = getattr(event, "content", None)
+source_content, msgtype = content, content.get("msgtype", "")
+```
+
+所以 `{"com.fmode.sessionId": "..."}` 会原样留在 `source_content` 里。
+
+**当前 m.room.message content 的完整结构(真实字段,基于 adapter 实际读取的键):**
+```json
+{
+  "msgtype": "m.text",
+  "body": "@bot 消息正文",
+  "format": "org.matrix.custom.html",
+  "formatted_body": "<b>消息正文</b>",
+  "m.mentions": {"user_ids": ["@zgdcmgnqcd:fmode.cn"]},
+  "m.relates_to": {
+    "rel_type": "m.thread",
+    "event_id": "$thread-root-event-id",
+    "is_falling_back": true,
+    "m.in_reply_to": {"event_id": "$parent-event-id"}
+  },
+  "com.fmode.sessionId": "你的会话id"
+}
+```
+
+**但有一个缺口必须说明:** `com.fmode.sessionId` 能被读到,**但当前 adapter 不会把它写进环境变量或状态文件** —— 读完之后 `source_content` 就在 `_handle_text_message` → `send_message` 链路里被丢弃了,没有透传到 `build_source`(L2063 只挑了 chat_id/thread_id/message_id 等固定字段)。
+
+**要做到「写进环境变量」需要两处小改动(都可行,改动量很小):**
+1. `adapter.py` L2063 的 `build_source(...)` 加一个自定义字段(或塞进 `chat_topic` 之类的现有扩展位)
+2. `gateway/session_context.py` L34 的 `_SESSION_VARS` 加一个 ContextVar,`set_session_vars` 加一个参数
+3. `gateway/run.py` L4170 的 `_set_session_vars` 透传
+
+**结论汇总:** Q1 ✅ 可行;Q2 ✅ 可行但**只能用 `HERMES_SESSION_THREAD_ID`,不能靠 session_id 反查**;Q3 ✅ 消息能到达,但**要落成环境变量需要 3 处约 10 行改动**,不改就只能在插件侧从 stdin payload 的 `extra` 拿(插件 hook 拿不到原始 content,所以这条走不通)。
+
+**一个建议起手点:** 既然 `HERMES_SESSION_THREAD_ID` 现成可用,Q3 的自定义 sessionId 字段大概率不必要——除非你要的是「跨消息稳定、与 Matrix thread 无关的会话 id」。如果只是想在线程里回复,用现有 thread event_id 就够。
+```
+
+### 27【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls -la /opt/data/plugins/ 2>/dev/null... ````
+- `💻 terminal ``` cd /opt/hermes && grep -rn "os.enviro... ````
+- `🔎 Searching files for HERMES_|environ\[`
+- `* 🔎 Searching files for HERMES_|environ\[ 💻 terminal ``` cd /opt/hermes && grep -n "env" agent... ````
+- `💻 terminal ``` cd /opt/hermes && grep -n "HERMES_" t... ``` 📖 Reading local.py L200-259`
+- `💻 terminal ``` cd /opt/hermes && grep -rn "HERMES_SE... ``` ``` cd /opt/hermes && grep -n "_VAR_MAP" ... ````
+- `💻 terminal ``` cd /opt/hermes && grep -n "_payload_f... ``` ``` cd /opt/hermes && grep -rn "set_sessi... ````

+ 282 - 0
docs/03-interface/容器追问-url为空与会话归属-20261110.md

@@ -0,0 +1,282 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T07:24:43.444Z
+- 房间:`!jptGcVflDw9xHtWldc1TLQSDrCF2IT9Tj6NApieU9eU`(临时房间,可保留查阅)
+- 消息统计:正文 17 段 / 工具过程 7 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+只读回答三个问题,不要产出任何文件、不要调 Report。
+
+Q1: 你 PUSH 成功后 ledger 里 url 是空字符串,但 ok 是 true。url 这个变量在代码里是从哪一步取到的?贴出给它赋值的代码行。
+Q2: report 调云函数时参数里只有 agentId、title、summary、artifacts、tags。如果我们在云函数侧新增一个 threadRoot 字段,你能把当前 Matrix 事件的 thread 根(m.relates_to.event_id)带上去吗?贴出你能拿到这个值的代码位置。
+Q3: App 每次新建临时房间,room_id 和 thread 根都不同。你有没有办法区分「同一个会话的后续消息」和「新会话」?贴出判断依据。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+只读核查,不产出文件、不调 Report。先找相关代码。
+```
+
+### 2【prose】
+
+```text
+ledger 记录里 url 并非空。接下来找 url 赋值位置和 report 调用处。
+```
+
+### 3【prose】
+
+```text
+```
+grep -n "agentId\|summary\|artifacts\...
+```
+📖 Reading fmode-deliverable-hook.py L415-544
+```
+
+### 4【prose】
+
+```text
+Q1 已有确凿证据。现在核查 Q2、Q3 所需的信息:hook 能不能拿到当前 Matrix 事件,session 与 thread 的绑定情况。
+```
+
+### 5【prose】
+
+```text
+* ```
+grep -n "agentId\|summary\|artifacts\...
+```
+📖 Reading fmode-deliverable-hook.py L415-544
+💻 terminal
+```
+grep -n "publicUrl\|public_url\|def c...
+```
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 6【prose】
+
+```text
+matrix_threads.json 只保存 event_id 列表,不含 session_key。接下来确认 shell hook 实际收到什么 payload,以及 gateway 里 thread 如何与会话绑定。
+```
+
+### 7【prose】
+
+```text
+```
+head -c 1200 /opt/data/matrix_threads...
+```
+```
+cd /opt/data && grep -rn "post_tool_c...
+```
+```
+
+### 8【prose】
+
+```text
+```
+grep -n "post_tool_call" -A6 -B6 /opt...
+```
+```
+
+### 9【prose】
+
+```text
+* ```
+grep -n "post_tool_call" -A6 -B6 /opt...
+```
+```
+grep -n "session_id\|chat_id\|room\|p...
+```
+📖 Reading shell_hooks.py L79-108
+```
+
+### 10【prose】
+
+```text
+现在要确认 post_tool_call 的 kwargs 里有没有 Matrix 事件信息,以及 session_id 里的 room/thread 映射关系。
+```
+
+### 11【prose】
+
+```text
+* ```
+grep -n "post_tool_call" -A6 -B6 /opt...
+```
+```
+grep -n "session_id\|chat_id\|room\|p...
+```
+📖 Reading shell_hooks.py L79-108
+💻 terminal
+```
+grep -rn "post_tool_call" /opt/hermes...
+```
+```
+grep -n "_emit_post_tool_call_hook" -...
+```
+```
+
+### 12【prose】
+
+```text
+post_tool_call 的 payload 里没有 Matrix 事件,只有 session_id、task_id 等。接下来定位 session_key 的构造方式,以便 Q3 的回答有依据。
+```
+
+### 13【prose】
+
+```text
+```
+grep -rn "def build_session_key" -A40...
+```
+```
+
+### 14【prose】
+
+```text
+```
+grep -rn "m.relates_to\|rel_type\|thr...
+```
+```
+grep -rln "matrix" /opt/hermes --incl...
+```
+```
+
+### 15【prose】
+
+```text
+关键证据齐了。最后确认 adapter 里 thread 根 event_id 的字段名。
+```
+
+### 16【prose】
+
+```text
+* ```
+grep -rn "m.relates_to\|rel_type\|thr...
+```
+```
+grep -rln "matrix" /opt/hermes --incl...
+```
+```
+date; echo "=== sessions db 里 matrix ...
+```
+```
+grep -n "m\.relates_to\|rel_type\|thr...
+```
+```
+
+### 17【prose】
+
+```text
+只读核查完毕,三个问题逐条回答。先说一个前提事实:**当前  里 28 条记录中,`ok:true` 且 `url` 为空的有 0 条**;`url` 为空的 3 条全部是 `ok:false` 的 put 失败记录(中文名 ASCII 报错 1 条、403 共 2 条)。因此您描述的「ok=true 但 url 是空字符串」的持久化记录,我在 ledger 里没有找到;下面按代码逻辑回答。
+
+## Q1:url 的取值位置
+
+`url` 只有一处赋值,在 `upload_and_register` 的 ③ 段:
+
+**
+```python
+url = j.get("publicUrl") or ""
+```
+
+其中 `j` 来自 `pendingArtifacts` 返回的 jobs 列表(第 487 行 `pend = push.call({"action": "pendingArtifacts", "agentId": agent_id})`,在第 506 行 `j = by_src.get(p)` 取得)。紧随其后:
+
+- 第 529 行 `push.call({"action": "ackArtifact", "objectId": j["id"], "ok": True, "publicUrl": url})`
+- 第 535 行 `urllib.request.urlopen(_q(url, ...))` 回读
+- 第 542 行 `res.append({"path": p, "ok": same, "url": url, "did": did, ...})`
+
+`ok` 与 `url` 之间没有绑定校验:`ok` 只取决于第 537 行 `same = hashlib.md5(got).hexdigest == hashlib.md5(data).hexdigest`。所以如果云函数返回的 job 缺 `publicUrl`,`url` 会变成 `""`;随后 `_q("")` 抛异常,进入 `except` 分支,得到 `same=False`,最终是 `ok:false url:""`,而不是 `ok:true`。**也就是说,在当前代码里,`ok:true` 配合 `url:""` 无法从上文这条路径产生**。
+
+唯一能产生 `ok:true` 的另一条路径是幂等复用分支,第 444 行和第 448 行:
+
+**
+```python
+cached_url = art["url"]
+owner = next(s for s in ck if os.path.basename(s) == nm)
+if _remote_matches(cached_url, ck[owner]):
+    log("REUSE %s url=%s did=%s" % (owner, cached_url, old.get("id")))
+    res.append({"path": owner, "ok": True, "url": cached_url,
+                "did": old.get("id"), "detail": "reused-existing"})
+```
+这条路径的 `url` 由平台历史记录的 `artifacts[].url` 提供(第 438 行已用 `not art.get("url")` 把空值过滤掉),也不可能是空串。
+
+补充一点:如果您看到的是**回复正文**里 url 为空,那是 `_scrub_fake_urls` / 第 642 行 `allowed_urls = {p.get("url") for p in st.get("pushed", []) ...}` 按 `pushed` 白名单裁剪的结果,与 ledger 记录无关。
+
+## Q2:threadRoot 能不能带上
+
+**能,但不是从当前 hook 通道拿到;需要改动注入方式。**现状与依据:
+
+- 云函数 report 的调用点在 `fmode-deliverable-hook.py:467-470`,参数确实只有 `agentId / title / summary / artifacts / tags`。
+- `post_tool_call` 和 `transform_llm_output` 的 stdin payload 结构在 **/opt/hermes/agent/shell_hooks.py:86-94**:
+  ```python
+  return {
+      "tool_name": kwargs.get("tool_name"),
+      "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None,
+      "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "",
+      "cwd": cwd,
+      "profile": get_active_profile_name,
+      "extra": {k: v for k, v in kwargs.items if k not in _TOP_LEVEL_PAYLOAD_KEYS},
+  }
+  ```
+  这里**没有** Matrix 事件字段。`_emit_post_tool_call_hook`(/opt/hermes/model_tools.py:691-696)传入的也只有 `task_id / session_id / tool_call_id / turn_id / api_request_id / duration_ms / status`。因此 **thread 根 `m.relates_to.event_id` 在 hook 进程里无法取到**。
+
+- 真正的 `m.relates_to.event_id` 在 adapter 里解析,**/opt/hermes/plugins/platforms/matrix/adapter.py:2015**:
+  ```python
+  thread_id = relates_to.get("event_id") if relates_to.get("rel_type") == "m.thread" else None
+  ```
+  真实线程根;若消息不在线程里,第 2054-2061 行按策略合成为 `thread_id = event_id`(即本条事件 ID 成为线程根)。随后第 2063-2066 行灌进 `build_source(..., thread_id=thread_id, message_id=event_id)`。
+
+**所以可行的落地位置有两处**(都需改代码,不属于本次只读范围):
+1. 在 **adapter.py:2066 之后**把 `thread_id` 写进一个 session 级状态文件(如 ),hook 再按 `session_id` 读出来 —— hook payload 里 `session_id` 是有的,这是最短路径。
+2. 或做一个 Python 插件在 `transform_llm_output` 回调里拿 session 的 source 元数据回填后再调 report。
+
+## Q3:能不能区分「后续消息」与「新会话」
+
+**在会话层面可以,靠 session_key,不靠 room_id / thread 根;但当前配置下自动分叉会让它失效。**
+
+- 判断依据的生成点:**/opt/hermes/gateway/session.py:686-694**,key 结构 `<ns>:<platform>:<chat_type>[:<chat_id>][:<thread_id>][:<user>]`:
+  ```python
+  parts = [_session_key_namespace(profile), source.platform.value, chat_type_slot]
+  if chat_id:
+      parts.append(chat_id)
+  thread_part = [thread_id] if thread_id else []
+  parts += user_part + thread_part if is_dm else thread_part + user_part
+  ```
+- 实际落盘证据( 137 条),Matrix 的 key 例如:
+  - `agent:main:matrix:group:!V2DEaZBWsXvSdNhFlWMRDBTBaQNtbMo9HkZX7oEcQMc:$l63L7QzBuM-fwvlKg3-4B1KlJYyKcxYerWY6UL4e0HU`(group + room_id + thread 根)
+  - `agent:main:matrix:dm:!jptGcVflDw9xHtWldc1TLQSDrCF2IT9Tj6NApieU9eU`(DM 只有 room_id,无 thread 段)
+  - `agent:main:matrix:dm:!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU:$approot_...`(DM + thread 根)
+
+  即:**同一 session_key ⇒ 同一会话的后续消息;session_key 不同 ⇒ 新会话**。对比时不看 room_id 单值,看整个 key 是否逐字节相同。
+
+- 但有个现行问题:群房间默认 `MATRIX_AUTO_THREAD=true` + `MATRIX_SESSION_SCOPE=auto`,adapter.py:2058-2061 会把每条不带 thread 的消息的 `event_id` 当线程根(`thread_id = event_id`),于是**每条消息都是新 key、新会话**。要让「后续消息」复用同一会话,条件在 adapter.py:2054 那个 `if not thread_id:` 分支:App 发消息时必须带 `m.relates_to{rel_type:"m.thread", event_id:<线程根>}`,真实 thread 优先于自动分叉;或改配置 `MATRIX_AUTO_THREAD=false` / `MATRIX_SESSION_SCOPE=room`。
+
+以上均为只读取证,未改动任何配置、未写文件、未调 Report。
+
+**已上云(可直接下载)**
+- https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/Tt8MiL3a3O/0-mwad74x2jp-matrix-threads.json
+- https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/Tt8MiL3a3O/1-mwad6pb6gs-sessions.json
+```
+
+### 18【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls /opt/data/work/ 2>/dev/null | head... ````
+- `🔎 Searching files for ledger 💻 terminal ``` grep -rn "ledger" /opt/data/scripts/f... ````
+- `* 🔎 Searching files for ledger 💻 terminal ``` grep -rn "ledger" /opt/data/scripts/f... ``` 🔎 Searching files for ledger|AgentDeliverable|artifacts 💻 terminal ``` tail -3 /opt/data/cache/deliverabl`
+- `* 🔎 Searching files for ledger 💻 terminal ``` grep -rn "ledger" /opt/data/scripts/f... ``` 🔎 Searching files for ledger|AgentDeliverable|artifacts 💻 terminal ``` tail -3 /opt/data/cache/deliverabl`
+- `📖 Reading adapter.py L2045-2089`
+- `* 📖 Reading adapter.py L2045-2089 💻 terminal ``` cd /opt/data && python3 - <<'EOF' ... ````
+- `* 📖 Reading adapter.py L2045-2089 💻 terminal ``` cd /opt/data && python3 - <<'EOF' ... ``` ``` grep -n "publicUrl" -B6 -A6 /opt/herm... ``` ``` cd /opt/data && python3 - <<'EOF' ... ````

+ 164 - 0
docs/03-interface/容器追问-产出文件走哪个函数-20261110.md

@@ -0,0 +1,164 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-10T08:11:01.443Z
+- 房间:`!7BbsnrMajSh5RrVaY6jP8LCyLges6x0vZc8ljb4Fmw0`(临时房间,可保留查阅)
+- 消息统计:正文 4 段 / 工具过程 11 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+只读回答,不产出文件、不调 Report。
+
+Q1: 你产出文件后调用的是哪个云函数?贴出完整函数 id 和 report 参数代码。
+Q2: 你知道 fmode-file-hub(函数 id THXAOGKiPK)这个新函数吗?调过没有?贴出调用记录。
+Q3: 你现在能拿到当前回合的 thread 根吗?贴出取值代码和实际取到的值。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先按只读方式核查真实文件与调用记录,不产出文件、不调 Report。
+```
+
+### 2【prose】
+
+```text
+Q1 的代码已经找到。现在继续查 Q2:是否调用过 THXAOGKiPK,并确认全局搜索为何返回 0 个匹配;同时查 Q3:thread 根的取值方式。
+```
+
+### 3【prose】
+
+```text
+环境变量里 HERMES_SESSION_THREAD_ID 已存在,但实际值为空。我会回读确认 Q1 的 report 参数,并核对 hook 日志中真实调用记录,确保内容不凭记忆。
+```
+
+### 4【prose】
+
+```text
+三个问题都已回读完成,以下内容全部来自原始文件和环境变量,未产出任何文件,也未调用 Report。
+
+---
+
+## Q1:产出文件后调用的是哪个云函数?
+
+**这是一次单一云函数调用,使用多 action 参数路由,不是多个函数。**
+
+函数 id:**`lfYlgU7SkK`**
+端点:``
+
+**report 参数代码(原文,`/opt/data/scripts/fmode-deliverable-hook.py` 第 466-476 行):**
+
+```python
+    # ① report:建成果记录(artifacts 里带原始中文名,成果表可读)
+    rep = push.call({"action": "report", "agentId": agent_id, "title": title,
+                     "summary": summary,
+                     "artifacts": [{"name": os.path.basename(p), "source": p} for p in live],
+                     "tags": ["auto-deliverable-hook"]})
+    did = rep.get("id")
+    if rep.get("code") != 200 or not did:
+        for p in live:
+            res.append({"path": p, "ok": False, "detail": "report-failed:%s" % json.dumps(rep, ensure_ascii=False)[:200]})
+        return res
+    log("REPORT did=%s agent=%s title=%s" % (did, agent_id, title))
+```
+
+**请求体组装(`/opt/data/scripts/fmode-artifact-push.py` 第 25-26、58-62 行):**
+
+```python
+BASE = ""
+FN = "lfYlgU7SkK"
+
+def call(params):
+    body = json.dumps({"id": FN, "token": TOK, "params": params}).encode()
+    req = urllib.request.Request(BASE, data=body, headers={
+        "Content-Type": "application/json",
+        "X-Parse-Application-Id": "ncloudmaster"})
+```
+
+所以 report 的真实线格式是 `POST /api/functions`,body 为 `{"id":"lfYlgU7SkK","token":<FMODE_SESSION_TOKEN>,"params":{"action":"report","agentId":...,"title":...,"summary":...,"artifacts":[...],"tags":["auto-deliverable-hook"]}}`。
+
+同一条链路上 `lfYlgU7SkK` 依次使用的 action 是:`report` → `enqueueArtifact` → `pendingArtifacts` → `ackArtifact`(另外幂等检查时还会用 `list`)。
+
+---
+
+## Q2:fmode-file-hub(THXAOGKiPK)这个新函数知道吗?调过没有?
+
+**这两个问题的答案都是“不”。**
+
+- 我不知道 `THXAOGKiPK` / `fmode-file-hub` 的存在:我的配置、技能、插件、脚本里都没有它的任何记载。这个函数是你这次问我才第一次听到的名字。
+- 我也从未调用过它。这一点有证据:全盘搜索 `/opt/data`,结果如下:
+  - `grep -rI 'THXAOGKiPK' /opt/data | wc -l` → **0**
+  - `grep -rI 'fmode-file-hub' /opt/data | wc -l` → **0**
+  - `grep -rI 'file-hub\|file_hub\|FileHub' /opt/data` → **无输出**
+
+实际调用记录(`/opt/data/cache/deliverable-hook/hook.log` 回读原文,最近几条):
+
+```
+2026-11-10T15:24:34 REPORT did=Tt8MiL3a3O agent=agent-node-lj-test title=matrix_threads.json 等2个文件
+2026-11-10T13:55:36 REPORT did=GqjZAuRb9O agent=agent-node-lj-test title=sessions.json 等2个文件
+2026-11-10T13:34:19 REPORT did=GFPZL9oZfT agent=agent-node-lj-test title=app-e2e-1794288808946.md
+2026-11-10T13:32:00 PUSH /opt/data/work/meeting/meeting-1794288693128.md ok=True url=
+```
+
+日志里出现的云函数只有 `lfYlgU7SkK`。因此 `THXAOGKiPK` 是**完全未接入**:我既不知道它的入参契约,也没有任何调用痕迹。需要你给参数约定后我才能接入,我不会凭猜测写 payload。
+
+---
+
+## Q3:现在能拿到当前回合的 thread 根吗?
+
+**拿不到。字段存在,但实际值为空。**
+
+取值代码,以及按容器环境给出 verbatim 回读结果:
+
+```bash
+grep -E '^HERMES_SESSION_(THREAD_ID|SCOPE_ID|PARENT_CHAT_ID|MESSAGE_ID|CHAT_ID)=' /proc/self/environ
+```
+
+实际输出(`env | grep` 回读原文):
+
+```
+HERMES_SESSION_THREAD_ID=
+HERMES_SESSION_SCOPE_ID=
+HERMES_SESSION_PARENT_CHAT_ID=
+HERMES_SESSION_MESSAGE_ID=$GVzSa8e1YAL5uJOEANdtaE72Ejg8MWQ_utdCo46uoWs
+HERMES_SESSION_CHAT_ID=!7BbsnrMajSh5RrVaY6jP8LCyLges6x0vZc8ljb4Fmw0
+HERMES_SESSION_CHAT_TYPE=dm
+HERMES_SESSION_ID=20261110_160903_7a83ff6e
+```
+
+结论逐条如下:
+
+- `HERMES_SESSION_THREAD_ID` 存在,但值为空字符串,所以 thread 根为空。
+- `HERMES_SESSION_PARENT_CHAT_ID` 也为空。
+- 我能拿到的只有房间 id(`!7BbsnrMajSh5RrVaY6jP8LCyLges6x0vZc8ljb4Fmw0`)和本条消息的 event_id(`$GVzSa8e1YAL5uJOEANdtaE72Ejg8MWQ_utdCo46uoWs`),拿不到这个 event 属于哪个 thread 根。
+- 当前会话是 DM,且 `HERMES_SESSION_CHAT_TYPE=dm`,thread_id 为空也符合这条链路的现状。
+
+---
+
+补充一句风险提示(不改配置,只报告):`/opt/data/cache/deliverable-hook/hook.log` 里存在把 `/opt/data/sessions/sessions.json` 和 `/opt/data/matrix_threads.json` 当交付成果上传登记的记录(`did=GqjZAuRb9O`、`did=Tt8MiL3a3O`)。按你自己定的规则,会话状态文件不该进成果表;这属于 hook 判定误判,要不要修由你决定,本轮只读,我没有改动。
+```
+
+### 5【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `🐍 Running code from hermes_tools import terminal, se...`
+- `🐍 Running code from hermes_tools import terminal pri...`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files...`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files... (×2) 🐍 Running code from hermes_tools import read_file pr...`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files... (×2) 🐍 Running code from hermes_tools import read_file pr... 🐍 Running code from h`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files... (×2) 🐍 Running code from hermes_tools import read_file pr... 🐍 Running code from h`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files... (×2) 🐍 Running code from hermes_tools import read_file pr... 🐍 Running code from h`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files... (×2) 🐍 Running code from hermes_tools import read_file pr... 🐍 Running code from h`
+- `* 🐍 Running code from hermes_tools import terminal pri... 🐍 Running code from hermes_tools import search_files... (×2) 🐍 Running code from hermes_tools import read_file pr... 🐍 Running code from h`
+- `🐍 Running code from hermes_tools import terminal # Q...`
+- `* 🐍 Running code from hermes_tools import terminal # Q... 🐍 Running code from hermes_tools import terminal # h...`

+ 35 - 0
docs/03-interface/待问容器-上云与会话逻辑-20261110.md

@@ -0,0 +1,35 @@
+请按 Q1 到 Q18 编号逐条回答下面五个部分。
+
+回答要求(很重要):
+1. 能贴命令输出就贴原文,不要总结。只写结论、没有命令输出的,算未回答。
+2. 查不到的,直接写「不确定」,不要猜。猜出来的答案对我们有害。
+3. 全程只读:不要改配置、不要重启、不要安装任何东西。
+4. 不要产出任何文件,也不要调用 Report。
+
+第一部分 当前状态
+Q1: 你当前运行的进程是什么?贴 ps -o pid,lstart,cmd 或等价输出,要能看到启动时间。
+Q2: 这三个组件现在在不在?贴路径和 ls -l 的修改时间:fmode-artifact-push.py、plugin-deliverable-inject、fmode-deliverable-hook。
+Q3: 上面这些组件是被哪个进程加载的?把进程启动时间和文件修改时间放在一起对比,谁更晚。
+Q4: 你最近一次成功调用成果相关云函数(report 或 enqueueArtifact)是什么时候?贴 state.db 或日志里的最后一条记录原文。
+
+第二部分 会话独立性
+Q5: 你怎么区分「同一个 App 会话」和「新建的临时房间」?贴你代码里做这个判断的那几行。
+Q6: session_key 的完整结构是什么?thread_id 在什么情况下是空的、什么情况下非空?
+Q7: 同一时刻来了两个不同会话的请求,你的工作目录和上下文会互相串吗?贴隔离的实现代码。
+
+第三部分 成果分配与入库
+Q8: 产出文件后,你判断「哪些文件算成果」的规则原文是什么?把扩展名白名单和排除清单都贴出来。
+Q9: 成果记录是你写进数据库,还是 App 写?如果是你写,贴出调用代码:函数 id、action、参数。
+Q10: 你写入成果时,怎么表示这个成果属于哪个会话?有没有带 threadRoot、sessionId 或 roomId?
+Q11: 历史上你调用 deliverable-report 一共多少次、成功几次?贴日志统计结果。
+
+第四部分 App 如何加载
+Q12: 你发文件时,除了 m.file 附件,还会在正文里写什么?有没有写 <!--fmode:files ...--> 这种结构化标记?
+Q13: 你认为 App 是从成果表读文件,还是从消息正文里找链接?你的依据是什么?
+Q14: 一个回合你一共发几条消息?文件链接出现在第几条?
+
+第五部分 底层请求与异常
+Q15: 把你上云这条链路的完整代码贴出来,也就是 fmode-artifact-push.py 的全文。
+Q16: 上传完怎么校验成功?贴 md5 回读那段代码。
+Q17: 本地源文件在什么时机删除?如果失败会留下什么?
+Q18: 生成链接失败时你在正文里写什么?我们观察到你写过「本地路径:(本地工作目录)」,贴出产生这句话的代码。

+ 2 - 0
docs/README.md

@@ -67,6 +67,8 @@
 | [2026-11-09-修改留痕-排版与数据清理.md](00-tracking/2026-11-09-修改留痕-排版与数据清理.md) | 生效 | v1.0 · AI 回复排版归一化(4 种形态)+ 74 条会话软删 + 墓碑拦截 |
 | [2026-11-09-修改留痕-成果按会话归档与文件上云.md](00-tracking/2026-11-09-修改留痕-成果按会话归档与文件上云.md) | 🔴 生效 | v1.0 · **发现容器成果文件全部丢失** + 成果按会话归档 + 单聊 Matrix/S3 双写 |
 | [2026-11-09-修改留痕-项目逻辑文档补齐.md](00-tracking/2026-11-09-修改留痕-项目逻辑文档补齐.md) | 生效 | v1.0 · 新增逻辑总览 + 需求对照表,索引重排 |
+| [**2026-11-10-交接-未完成事项清单.md**](00-tracking/2026-11-10-交接-未完成事项清单.md) | 🔴 生效 | **新会话必读**:本轮已完成 / 未完成 P0-P2 / 需问容器的信息 / 关键流程 |
+| [2026-11-10-修改留痕-会话区三缺陷与上云链路.md](00-tracking/2026-11-10-修改留痕-会话区三缺陷与上云链路.md) | 生效 | v1.0 · 计时稳定 / 日志不刷屏 / 超时 3 分钟 / 上云链路定位到容器侧 |
 
 ### 01-requirements/ 需求文档
 | 文档 | 状态 | 说明 |

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "verify:cloud": "node scripts/verify-cloud-fn.mjs",
     "serve:dist": "node scripts/serve-dist.mjs",
     "dev:logged-in": "node scripts/serve-dist.mjs --login",
+    "verify:upload": "node scripts/verify-upload.mjs",
     "ops:clear-deliverables": "node scripts/clear-deliverable-urls.mjs",
     "verify:deliverables": "node scripts/verify-deliverable-separation.mjs",
     "verify:ticking": "node scripts/verify-elapsed-ticking.mjs"

+ 235 - 0
scripts/ask-agent.mjs

@@ -0,0 +1,235 @@
+#!/usr/bin/env node
+/**
+ * 向容器提问并收集完整回答(取证用)
+ *
+ * 用法:
+ *   node scripts/ask-agent.mjs --question-file docs/xxx问题清单.md
+ *   node scripts/ask-agent.mjs --question-file q.md --agent agent-node-lj-test
+ *   node scripts/ask-agent.mjs --question-file q.md --out docs/xxx-问答原文.md
+ *
+ * ## 与 ask-ljtest.mjs 的区别(2026-11-10 重新设计)
+ *
+ * ask-ljtest.mjs 有两个硬伤,导致拿不到详细答案:
+ *   ① 问题写死在代码里,不能传
+ *   ② 收到**第一条正文就停** —— 容器会把长回答拆成多条消息,
+ *      中间还夹 💻 工具过程行,所以它只能读到第一段
+ *
+ * 本脚本:
+ *   · 从文件读问题(整份清单一次发,编号 Q1/Q2/…)
+ *   · **累积全部容器消息**,工具过程行只标记不终止
+ *   · 结束条件:收到 ✅ reaction(容器自述的完成信号)或静默 N 秒,取先到
+ *   · 完整问答原文落盘
+ *
+ * ## 为什么不能"收到第一条正文就停"
+ *
+ * 实测(docs/00-tracking/2026-11-09-修改留痕-五项缺陷修复第二轮.md:18-30):
+ * 一轮 9 条事件 = 5 段正文 + 4 段过程,正文被过程反复打断。
+ * 且容器自述"一轮结束在 Matrix 里没有信号,只有 status=idle,延迟 1~3 秒"。
+ */
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+
+const FUNCTIONS = 'https://server.fmode.cn/api/functions';
+const FN_PROVISION = 'RP5Ht55NeX';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/** 工具过程行的识别(与 verify-upload.mjs:160 同口径) */
+const TOOL_LINE = /^[*·•]?\s*(🐍|💻|🔎|📖|✍️|🧠|💾|🔧)/;
+
+function arg(name, fallback) {
+  const i = process.argv.indexOf(`--${name}`);
+  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
+}
+
+const AGENT = arg('agent', 'agent-node-lj-test');
+const QUESTION_FILE = arg('question-file', null);
+/** 静默多少毫秒算答完。容器一轮结束无信号,返回 0 我们也不指望它;只能靠静默。 */
+const SILENCE_MS = Number(arg('silence', '120000'));
+/** 总超时:底盘,防止无限等 */
+const MAX_MS = Number(arg('max', '1800000'));
+const OUT = arg('out', null);
+
+function loadToken() {
+  const p = join(homedir(), '.fmode', 'config', 'user.json');
+  const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
+  if (typeof t !== 'string' || !t.startsWith('r:')) throw new Error(`读不到会话凭据:${p}`);
+  return t;
+}
+
+const TOKEN = loadToken();
+
+async function provision(agentId) {
+  const r = await fetch(FUNCTIONS, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'ncloudmaster' },
+    body: JSON.stringify({ id: FN_PROVISION, token: TOKEN, params: { agentId } }),
+  });
+  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;
+}
+
+async function main() {
+  if (!QUESTION_FILE) {
+    console.error('用法:node scripts/ask-agent.mjs --question-file <路径> [--agent id] [--out 路径]');
+    process.exit(2);
+  }
+  const QUESTION = readFileSync(QUESTION_FILE, 'utf8').trim();
+  if (!QUESTION) throw new Error('问题文件为空');
+
+  console.log(`\n=== 向容器取证 ===`);
+  console.log(`容器    : ${AGENT}`);
+  console.log(`问题文件: ${QUESTION_FILE}(${QUESTION.length} 字)`);
+  console.log(`静默阈值: ${SILENCE_MS / 1000}s\n`);
+
+  const prov = await provision(AGENT);
+  const agent = await provision(AGENT);
+  console.log('用户:', prov.userMxId, '| 容器:', agent.agentMxId);
+
+  const mx = await import('matrix-js-sdk');
+  const client = mx.createClient({
+    baseUrl: prov.homeserver,
+    accessToken: prov.accessToken,
+    userId: prov.userMxId,
+  });
+  const room = (
+    await client.createRoom({
+      invite: [agent.agentMxId],
+      name: `ask-${Date.now()}`,
+      preset: mx.Preset.TrustedPrivateChat,
+    })
+  ).room_id;
+  console.log('房间:', room);
+
+  const hs = prov.homeserver.replace(/\/$/, '');
+  const auth = { Authorization: `Bearer ${prov.accessToken}` };
+
+  let joined = false;
+  for (let i = 0; i < 30; i++) {
+    await sleep(3000);
+    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 ?? {})[agent.agentMxId]) {
+      joined = true;
+      console.log('容器已进房');
+      break;
+    }
+  }
+  if (!joined) throw new Error('容器 90 秒内未进房');
+
+  const sent = await client.sendEvent(room, 'm.room.message', {
+    msgtype: 'm.text',
+    body: QUESTION,
+    'm.mentions': { user_ids: [agent.agentMxId] },
+  });
+  console.log('问题已发送:', sent.event_id, '\n');
+
+  /* ---- 收集 ---- */
+  const seen = new Set([sent.event_id]);
+  const events = []; // { ts, body, kind }
+  let lastActivity = Date.now();
+  let acked = false;
+  const deadline = Date.now() + MAX_MS;
+
+  while (Date.now() < deadline) {
+    await sleep(5000);
+    try {
+      const r = await fetch(
+        `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=100`,
+        { headers: auth },
+      );
+      const j = await r.json();
+      const fresh = [];
+      for (const ev of (j.chunk ?? []).reverse()) {
+        if (seen.has(ev.event_id)) continue;
+        seen.add(ev.event_id);
+
+        /* ✅ reaction 是容器自述的"完成"信号(直连手册 :62) */
+        if (ev.type === 'm.reaction') {
+          const rel = ev.content?.['m.relates_to'];
+          if (rel?.event_id === sent.event_id && String(rel.key ?? '').includes('✅')) {
+            acked = true;
+            fresh.push({ ts: Date.now(), kind: 'ack', body: '✅ 容器已标记完成' });
+          }
+          continue;
+        }
+        if (ev.type !== 'm.room.message' || ev.sender === prov.userMxId) continue;
+
+        const body = String(ev.content?.body ?? '');
+        const kind = TOOL_LINE.test(body.trim()) ? 'tool' : 'prose';
+        const media = ev.content?.msgtype === 'm.file' || ev.content?.msgtype === 'm.image';
+        fresh.push({ ts: Date.now(), kind: media ? 'file' : kind, body });
+      }
+      if (fresh.length > 0) {
+        for (const f of fresh) {
+          events.push(f);
+          const tag = f.kind === 'tool' ? '工具' : f.kind === 'file' ? '文件' : f.kind === 'ack' ? '完成' : '正文';
+          console.log(`[${tag}] ${f.body.slice(0, 120).replace(/\n/g, ' ')}${f.body.length > 120 ? '…' : ''}`);
+        }
+        lastActivity = Date.now();
+      }
+    } catch {
+      /* 轮询抖动忽略 */
+    }
+    if (acked) {
+      console.log('\n收到 ✅,容器标记完成');
+      break;
+    }
+    if (Date.now() - lastActivity >= SILENCE_MS) {
+      console.log(`\n静默 ${SILENCE_MS / 1000}s,判定答完`);
+      break;
+    }
+  }
+
+  /* ---- 落盘 ---- */
+  const prose = events.filter((e) => e.kind === 'prose');
+  const report = [
+    `# 容器取证问答:${AGENT}`,
+    ``,
+    `- 时间:${new Date().toISOString()}`,
+    `- 房间:\`${room}\`(临时房间,可保留查阅)`,
+    `- 消息统计:正文 ${prose.length} 段 / 工具过程 ${events.filter((e) => e.kind === 'tool').length} 段 / 文件 ${events.filter((e) => e.kind === 'file').length} 个`,
+    `- 结束原因:${acked ? '收到 ✅ reaction' : '静默超时'}`,
+    ``,
+    `## 我发出的问题`,
+    ``,
+    '```text',
+    QUESTION,
+    '```',
+    ``,
+    `## 容器回答(原文,按时间顺序,未删改)`,
+    ``,
+    ...events
+      .filter((e) => e.kind !== 'tool')
+      .map((e, i) => `### ${i + 1}【${e.kind}】\n\n\`\`\`text\n${e.body}\n\`\`\`\n`),
+    `---`,
+    ``,
+    `## 工具过程(仅供核对,非回答内容)`,
+    ``,
+    ...(events.filter((e) => e.kind === 'tool').length
+      ? events.filter((e) => e.kind === 'tool').map((e) => `- \`${e.body.slice(0, 200).replace(/\n/g, ' ')}\``)
+      : ['(无)']),
+  ].join('\n');
+
+  const outPath = OUT ?? `docs/03-interface/容器取证-${AGENT}-${Date.now()}.md`;
+  mkdirSync(dirname(outPath), { recursive: true });
+  writeFileSync(outPath, report, 'utf8');
+
+  console.log(`\n=== 汇总 ===`);
+  console.log(`正文段数: ${prose.length}`);
+  console.log(`落盘    : ${outPath}`);
+  console.log(`房间保留: ${room}`);
+  client.stopClient();
+  process.exit(prose.length > 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+  console.error('失败:', e instanceof Error ? e.message : e);
+  process.exit(2);
+});

+ 78 - 0
scripts/ask-ljtest.mjs

@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+/**
+ * 向 agent-node-lj-test 分批发短问题(长问题容器会处理失败)
+ */
+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';        // 🔴 只问这个,不是 app-lj-test-v1
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const t = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8'))?.sessionToken;
+
+async function provision(id) {
+  const r = await fetch(F, { method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: 'RP5Ht55NeX', token: t, params: { agentId: id } }) });
+  const j = await r.json();
+  const d = j.result ?? j;
+  if (!d?.accessToken) throw new Error('provision 失败');
+  return d;
+}
+
+/** 分批发:每条都很短,避免容器处理不了长消息 */
+const QUESTIONS = [
+  '你的回复是分几条消息发,还是攒成一整段才发?简短回答。',
+  '你的回复会挂 Matrix thread 吗?就是 m.relates_to 里的 rel_type=m.thread。会还是不会?',
+  'display.tool_progress 和 display.interim_assistant_messages 现在是什么值?',
+];
+
+async function main() {
+  const prov = await provision(TARGET);
+  const agent = await provision(TARGET);
+  console.log('目标容器:', TARGET, '| agentMxId:', agent.agentMxId);
+  console.log('用户:', prov.userMxId);
+
+  const mx = await import('matrix-js-sdk');
+  const c = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId });
+  const room = (await c.createRoom({ invite: [agent.agentMxId], name: `ask-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat })).room_id;
+  console.log('房间:', room);
+
+  const hs = prov.homeserver.replace(/\/$/, '');
+  const auth = { Authorization: `Bearer ${prov.accessToken}` };
+  for (let i = 0; i < 30; i++) {
+    await sleep(3000);
+    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 ?? {})[agent.agentMxId]) { console.log('容器已进房'); break; }
+  }
+
+  const seen = new Set();
+  for (const [i, q] of QUESTIONS.entries()) {
+    console.log(`\n===== 问题 ${i + 1} =====`);
+    console.log(q);
+    const sent = await c.sendEvent(room, 'm.room.message', { msgtype: 'm.text', body: q, 'm.mentions': { user_ids: [agent.agentMxId] } });
+    seen.add(sent.event_id);
+
+    const deadline = Date.now() + 180_000;
+    let got = false;
+    while (Date.now() < deadline && !got) {
+      await sleep(6000);
+      try {
+        const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=30`, { headers: auth });
+        const j = await r.json();
+        for (const ev of (j.chunk ?? []).reverse()) {
+          if (ev.type !== 'm.room.message' || seen.has(ev.event_id) || ev.sender === prov.userMxId) continue;
+          seen.add(ev.event_id);
+          const b = String(ev.content?.body ?? '');
+          console.log(`  → 容器(${b.length}字):`, b.slice(0, 500).replace(/\n/g, ' '));
+          if (!/^[*·•]?\s*(🐍|💻|🔎|📖|✍️)/.test(b.trim())) got = true;
+        }
+      } catch { /* ignore */ }
+    }
+    if (!got) console.log('  ⚠️ 180 秒内无文字回复');
+  }
+  console.log('\n房间保留:', room);
+  c.stopClient();
+}
+main().catch((e) => { console.error('失败:', e.message); process.exit(1); });

+ 60 - 2
scripts/deploy-cloud-fn.mjs

@@ -39,7 +39,11 @@ const BASE = 'https://server.fmode.cn/parse';
 const MK = process.env.PARSE_MASTER_KEY;
 const APPLY = process.argv.includes('--apply');
 
-/** 要部署的函数清单 */
+/** 要部署的函数清单
+ *
+ * `create: true` 表示「线上还没这条就新建」——新建后会在控制台打印 objectId,
+ * 需要把它写回 `src/app/core/api/parse-api.ts` 的 `FN` 常量。
+ */
 const TARGETS = [
   {
     file: 'cloud/functions/fmodeagent-chatthreads.js',
@@ -47,6 +51,13 @@ const TARGETS = [
     name: 'fmodeagent-chatthreads',
     version: '0.1.9',
   },
+  {
+    file: 'cloud/functions/fmode-file-hub.js',
+    id: null, // 新建前未知,部署时按 name 查
+    name: 'fmode-file-hub',
+    version: '0.1.0',
+    create: true,
+  },
 ];
 
 function authHeaders() {
@@ -63,6 +74,30 @@ async function getRemote(id) {
   return res.json();
 }
 
+/** 按函数名查 objectId(新建前用于判断「线上是否已存在」) */
+async function findByName(name) {
+  const where = encodeURIComponent(JSON.stringify({ name }));
+  const res = await fetch(`${BASE}/classes/Function?where=${where}&limit=1`, {
+    headers: authHeaders(),
+  });
+  if (!res.ok) throw new Error(`按名查询失败 HTTP ${res.status}`);
+  const j = await res.json();
+  return (j.results ?? [])[0] ?? null;
+}
+
+/** 新建云函数(POST /classes/Function),返回新 objectId */
+async function createRemote(name, code, version) {
+  const res = await fetch(`${BASE}/classes/Function`, {
+    method: 'POST',
+    headers: authHeaders(),
+    body: JSON.stringify({ name, code, version, enabled: true }),
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`新建失败 HTTP ${res.status}: ${text.slice(0, 200)}`);
+  const j = JSON.parse(text);
+  return j.objectId;
+}
+
 /** PUT 有频率限制,403 时退避重试 */
 async function putRemote(id, body, attempt = 1) {
   const res = await fetch(`${BASE}/classes/Function/${id}`, {
@@ -96,9 +131,32 @@ async function main() {
 
   for (const t of TARGETS) {
     const code = readFileSync(resolve(root, t.file), 'utf8');
-    console.log(`\n=== ${t.name} (${t.id}) ===`);
+    console.log(`\n=== ${t.name} (${t.id ?? '待新建'}) ===`);
     console.log(`  本地源码:${t.file},${code.length} 字符,v${t.version}`);
 
+    /* 新建分支:线上还没有这条 Function 记录 */
+    if (t.create) {
+      let existing = null;
+      try {
+        existing = await findByName(t.name);
+      } catch (e) {
+        console.log(`  ⚠️ 按名查询失败:${e.message}`);
+      }
+      if (!existing) {
+        console.log('  → 线上不存在,将新建');
+        if (!APPLY) {
+          console.log('  → 加 --apply 才会真正新建');
+          continue;
+        }
+        const id = await createRemote(t.name, code, t.version);
+        console.log(`  ✅ 已新建,objectId = ${id}`);
+        console.log(`  🔴 请把它写进 src/app/core/api/parse-api.ts 的 FN 常量`);
+        continue;
+      }
+      t.id = existing.objectId;
+      console.log(`  → 线上已存在,objectId = ${t.id},转为更新`);
+    }
+
     let remote;
     try {
       remote = await getRemote(t.id);

+ 35 - 0
scripts/verify-auto-switch.mjs

@@ -0,0 +1,35 @@
+/** 验证:按屏幕宽度自动进入移动端/桌面端,不再手动切换 */
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { loadDevLogin } from './dev-login.mjs';
+const OUT = resolve('test-results/fix-20261110'); mkdirSync(OUT, { recursive: true });
+const login = loadDevLogin();
+const b = await chromium.launch({ headless: true, channel: 'chromium' });
+
+async function testAt(name, viewport) {
+  const ctx = await b.newContext({ viewport, isMobile: viewport.width < 768, hasTouch: viewport.width < 768 });
+  const p = await ctx.newPage();
+  await p.addInitScript((d) => {
+    localStorage.setItem('fmode_session_token', d.token);
+    localStorage.setItem('fmode_me', JSON.stringify(d.me));
+  }, login.payload);
+  // 访问根路径,看自动分流
+  await p.goto('http://localhost:4200/', { waitUntil: 'domcontentloaded' });
+  await p.waitForTimeout(5000);
+  const url = p.url();
+  const expect = viewport.width < 768 ? '/mobile/' : '/desktop/';
+  const ok = url.includes(expect);
+  console.log(`${name} (${viewport.width}px) → ${url.replace('http://localhost:4200', '')}  ${ok ? '✅' : '❌ 应为 ' + expect}`);
+  await p.screenshot({ path: `${OUT}/90-${name}.png` });
+  await ctx.close();
+  return ok;
+}
+
+const r1 = await testAt('phone', { width: 390, height: 844 });
+const r2 = await testAt('tablet', { width: 1024, height: 768 });
+const r3 = await testAt('desktop', { width: 1440, height: 900 });
+const r4 = await testAt('narrow-window', { width: 600, height: 900 });
+
+console.log('\n全部通过:', r1 && r2 && r3 && r4 ? '✅' : '❌');
+await b.close();

+ 99 - 0
scripts/verify-default-upload.mjs

@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/**
+ * 验收:容器是否**默认**自动上云(提示词不含任何上传暗示)
+ *
+ * 判定三条:
+ *   ① 回复里自动带出 https://s3.fmode.cn 链接
+ *   ② 成果表自动多一条记录
+ *   ③ 过程缓存没有被误传(成果数不应暴涨)
+ */
+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 WAIT_MS = 420_000;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const t = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8'))?.sessionToken;
+async function callFn(id, params) {
+  const r = await fetch(F, { method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id, token: t, params }) });
+  return r.json();
+}
+async function provision(id) {
+  const j = await callFn('RP5Ht55NeX', { agentId: id });
+  return j.result ?? j;
+}
+async function countDeliverables() {
+  const d = await callFn('lfYlgU7SkK', { action: 'list', agentId: TARGET, limit: 200 });
+  const list = d.deliverables ?? d.result?.deliverables ?? [];
+  return list.length;
+}
+
+/* 🔴 全程不出现:上传 / 云盘 / S3 / 链接 / URL / 成果 / 分享 */
+const PROMPT = `帮我写一份这周的工作周报,用 Markdown,分成「本周完成」「下周计划」「风险」三块。`;
+
+async function main() {
+  const before = await countDeliverables();
+  console.log('测试前成果数:', before);
+
+  const prov = await provision(TARGET);
+  const agent = await provision(TARGET);
+  const mx = await import('matrix-js-sdk');
+  const c = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId });
+  const room = (await c.createRoom({ invite: [agent.agentMxId], name: `accept-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat })).room_id;
+
+  const hs = prov.homeserver.replace(/\/$/, '');
+  const auth = { Authorization: `Bearer ${prov.accessToken}` };
+  for (let i = 0; i < 30; i++) {
+    await sleep(3000);
+    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 ?? {})[agent.agentMxId]) break;
+  }
+
+  console.log('\n=== 提示词(无任何上传暗示)===');
+  console.log(PROMPT);
+
+  const sent = await c.sendEvent(room, 'm.room.message', { msgtype: 'm.text', body: PROMPT, 'm.mentions': { user_ids: [agent.agentMxId] } });
+  const seen = new Set([sent.event_id]);
+  const deadline = Date.now() + WAIT_MS;
+  const texts = [];
+  let finished = false;
+  while (Date.now() < deadline) {
+    await sleep(8000);
+    try {
+      const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=50`, { headers: auth });
+      const j = await r.json();
+      let hasUrl = false;
+      for (const ev of (j.chunk ?? []).reverse()) {
+        if (ev.type !== 'm.room.message' || seen.has(ev.event_id) || ev.sender === prov.userMxId) continue;
+        seen.add(ev.event_id);
+        const b = String(ev.content?.body ?? '');
+        texts.push(b);
+        if (/https:\/\/s3\.fmode\.cn/.test(b)) hasUrl = true;
+      }
+      if (hasUrl) { finished = true; break; }
+    } catch { /* 轮询抖动 */ }
+  }
+
+  const all = texts.join('\n');
+  console.log('\n=== 最后一条正文 ===');
+  const prose = texts.filter((b) => !/^[*·•]?\s*(🐍|💻|🔎|📖|✍️)/.test(b.trim()));
+  console.log((prose[prose.length - 1] ?? '(无)').slice(0, 1500));
+
+  await sleep(8000); // 等成果表写入
+  const after = await countDeliverables();
+
+  console.log('\n=== 验收判定 ===');
+  const r1 = /https:\/\/s3\.fmode\.cn/.test(all);
+  console.log(`① 回复自动带 https 链接      : ${r1 ? '✅ 是' : '❌ 否'}`);
+  console.log(`② 成果表新增                 : ${after - before} 条(${before} → ${after})${after > before ? '✅' : '❌'}`);
+  console.log(`③ 未误传过程缓存             : ${after - before <= 2 ? '✅ 未暴涨' : `⚠️ 新增 ${after - before} 条,可能误传`}`);
+  console.log(`④ 正文是否残留本地路径        : ${/\/opt\/data\//.test(prose.join('\n')) ? '⚠️ 是' : '✅ 否'}`);
+  console.log(`\n总判定: ${r1 && after > before ? '✅ 通过 —— 默认上云已生效' : '❌ 未通过'}`);
+  c.stopClient();
+}
+main().catch((e) => { console.error('失败:', e.message); process.exit(1); });

+ 264 - 0
scripts/verify-file-hub.mjs

@@ -0,0 +1,264 @@
+/**
+ * 真实端到端验证:文件台账(按会话隔离)
+ *
+ * 用真实浏览器 + 真实账号 + 真实云函数,验证用户要求的三件事:
+ *   ① 用户上传的文件能自动上云并入库
+ *   ② 右侧栏**直接按会话**从数据库加载,不依赖加载聊天历史
+ *   ③ 会话之间完全隔离(A 会话的文件不出现在 B 会话)
+ *
+ * 前置:`npm start` 已在 4200 端口运行。
+ * 用法:`node scripts/verify-file-hub.mjs`
+ */
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { execSync } from 'node:child_process';
+import { loadDevLogin } from './dev-login.mjs';
+
+const OUT = resolve('test-results/file-hub-20261110');
+mkdirSync(OUT, { recursive: true });
+
+const login = loadDevLogin();
+if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`);
+
+const TOKEN = login.payload.token;
+const FN = 'THXAOGKiPK';
+
+const results = [];
+function check(name, ok, detail = '') {
+  results.push({ name, ok });
+  console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `  (${detail})` : ''}`);
+}
+
+/** 直接调云函数(模拟 App 侧 Api 层) */
+async function callFn(params) {
+  const r = await fetch('https://server.fmode.cn/api/functions', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: FN, token: TOKEN, params }),
+  });
+  return r.json();
+}
+
+/** 直接查库(绕开云函数,作为「数据库里真有这条」的独立证据) */
+async function dbQuery(threadRoot) {
+  const where = encodeURIComponent(JSON.stringify({ threadRoot }));
+  const r = await fetch(
+    `https://server.fmode.cn/parse/classes/AgentDeliverable?where=${where}&limit=50`,
+    {
+      headers: {
+        'X-Parse-Application-Id': 'ncloudmaster',
+        'X-Parse-Master-Key': 'SnkK12*&sunq2#@20!',
+      },
+    },
+  );
+  const j = await r.json();
+  return j.results ?? [];
+}
+
+const runId = Date.now();
+const SESSION_A = `$approot_verify_a_${runId}:fmode.cn`;
+const SESSION_B = `$approot_verify_b_${runId}:fmode.cn`;
+const created = [];
+
+async function main() {
+  console.log('\n=== 文件台账端到端验证 ===');
+  console.log(`会话 A: ${SESSION_A}`);
+  console.log(`会话 B: ${SESSION_B}\n`);
+
+  /* ---------- ① 真实上传:签名 → PUT OBS → 回读 ---------- */
+  const fileName = `verify-${runId}.md`;
+  const content = `# 文件台账验证\n\nrunId=${runId}\n真实上传内容。\n`;
+
+  const sig = await callFn({ action: '__none__' }).catch(() => null);
+  const up = await fetch('https://server.fmode.cn/api/functions', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      id: 'AlP56LCKFm',
+      token: TOKEN,
+      params: { filename: fileName, mimeType: 'text/markdown', size: content.length },
+    }),
+  }).then((r) => r.json());
+
+  if (up.code !== 200) {
+    check('① 取上传签名', false, JSON.stringify(up).slice(0, 120));
+    return finish();
+  }
+  check('① 取上传签名', true, `key 含用户前缀`);
+
+  const putRes = await fetch(up.uploadUrl, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'text/markdown', 'x-obs-acl': 'public-read' },
+    body: content,
+  });
+  check('② 真实 PUT 到云盘', putRes.ok, `HTTP ${putRes.status}`);
+
+  const readBack = await fetch(up.publicUrl).then((r) => r.text());
+  check('③ 回读内容一致', readBack === content, `${readBack.length} 字节`);
+
+  /* ---------- ② 登记到台账(会话 A) ---------- */
+  const reg = await callFn({
+    action: 'registerFile',
+    threadRoot: SESSION_A,
+    agentId: 'agent-node-lj-test',
+    name: fileName,
+    url: up.publicUrl,
+    key: up.key,
+    mimeType: 'text/markdown',
+    size: content.length,
+    uploadedBy: 'user',
+  });
+  check('④ 登记到文件台账', reg.code === 200 && !!reg.id, `id=${reg.id}`);
+  if (reg.id) created.push(reg.id);
+
+  /* 幂等:再登记一次不应产生新记录 */
+  const reg2 = await callFn({
+    action: 'registerFile',
+    threadRoot: SESSION_A,
+    name: fileName,
+    url: up.publicUrl,
+    uploadedBy: 'user',
+  });
+  check('⑤ 重复登记幂等', reg2.duplicated === true && reg2.id === reg.id, 'duplicated=true');
+
+  /* ---------- ③ 直接查库:数据库里真有 ---------- */
+  const rowsA = await dbQuery(SESSION_A);
+  check(
+    '⑥ 数据库里确实有一条(绕开云函数直查)',
+    rowsA.length === 1 && rowsA[0].threadRoot === SESSION_A,
+    `${rowsA.length} 条`,
+  );
+  const row = rowsA[0] ?? {};
+  check(
+    '⑦ 文件信息完整入库',
+    row.uploadedBy === 'user' && row.fileName === fileName && row.mimeType === 'text/markdown',
+    `uploadedBy=${row.uploadedBy} size=${row.fileSize}`,
+  );
+
+  /* ---------- ④ 会话隔离 ---------- */
+  const listB = await callFn({ action: 'listBySession', threadRoot: SESSION_B });
+  check(
+    '⑧ 会话隔离:B 会话查不到 A 的文件',
+    listB.code === 200 && listB.files.length === 0,
+    `B 返回 ${listB.files?.length ?? '?'} 条`,
+  );
+
+  const rowsB = await dbQuery(SESSION_B);
+  check('⑨ 数据库层面也隔离', rowsB.length === 0, `${rowsB.length} 条`);
+
+  const listA = await callFn({ action: 'listBySession', threadRoot: SESSION_A });
+  check(
+    '⑩ A 会话能查到自己的文件',
+    listA.code === 200 && listA.files.length === 1 && listA.files[0].name === fileName,
+    `${listA.files?.length} 条`,
+  );
+
+  /* ---------- ⑤ 真实浏览器:「从数据库加载」不依赖聊天历史 ---------- */
+  await browserCheck();
+
+  finish();
+}
+
+/**
+ * 浏览器验证:打开一个**全新会话**(没有任何聊天历史),
+ * 先往台账里写一条文件,再看右侧栏能否直接显示。
+ *
+ * 这正是用户的要求:「每次完全从数据库里面加载文件和在线的云盘链接」。
+ */
+async function browserCheck() {
+  const b = await chromium.launch({ headless: true, channel: 'chromium' });
+  const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+  const page = await ctx.newPage();
+  await page.addInitScript((d) => {
+    localStorage.setItem('fmode_session_token', d.token);
+    localStorage.setItem('fmode_me', JSON.stringify(d.me));
+  }, login.payload);
+
+  const errors = [];
+  page.on('console', (m) => {
+    if (m.type() === 'error') errors.push(m.text());
+  });
+
+  await page.goto('http://localhost:4200/desktop/chat', { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(6000);
+
+  /* 拦截云函数调用(要在点开会话**之前**注册,否则漏掉首批请求) */
+  const calls = [];
+  page.on('request', (req) => {
+    if (req.url().includes('/api/functions') && req.method() === 'POST') {
+      try {
+        const body = JSON.parse(req.postData() ?? '{}');
+        if (body.id === FN) calls.push(body.params);
+      } catch {
+        /* 忽略非 JSON */
+      }
+    }
+  });
+
+  await page.screenshot({ path: `${OUT}/01-desktop-chat.png`, fullPage: false });
+
+  /*
+   * 点开一个真实会话。侧栏条目是 <button>/<div>,用文本匹配更稳:
+   * 取「日常对话」分组里第一个会话标题点击。
+   */
+  let clicked = false;
+  for (const sel of ['.session-item', '.rail-session', '[data-session-id]']) {
+    const loc = page.locator(sel);
+    if ((await loc.count()) > 0) {
+      await loc.first().click({ timeout: 5000 }).catch(() => undefined);
+      clicked = true;
+      break;
+    }
+  }
+  if (!clicked) {
+    // 退路:按文本点第一个会话标题(列表里「写一份团队周报模板的…」这类)
+    const byText = page.getByText(/写一份团队周报/).first();
+    if ((await byText.count()) > 0) {
+      await byText.click({ timeout: 5000 }).catch(() => undefined);
+      clicked = true;
+    }
+  }
+  await page.waitForTimeout(5000);
+  await page.screenshot({ path: `${OUT}/02-after-session-click.png` });
+  console.log(`  会话点击: ${clicked ? '已点击' : '未找到可点条目'}`);
+  console.log(`  页面 URL: ${page.url().replace('http://localhost:4200', '')}`);
+
+  check(
+    '⑪ 浏览器里右侧栏真的调用了 listBySession',
+    calls.some((p) => p?.action === 'listBySession'),
+    calls.length ? `调用 ${calls.length} 次` : '未捕获到调用',
+  );
+
+  const fatal = errors.filter((e) => !/favicon|ResizeObserver/.test(e));
+  check('⑫ 页面无致命 JS 错误', fatal.length === 0, fatal.slice(0, 2).join(' | '));
+
+  await ctx.close();
+  await b.close();
+}
+
+function finish() {
+  /* 清理测试数据(成果表无删除接口,用 MasterKey 直删) */
+  for (const id of created) {
+    try {
+      execSync(
+        `curl -s -X DELETE "https://server.fmode.cn/parse/classes/AgentDeliverable/${id}" ` +
+          `-H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: SnkK12*&sunq2#@20!" -o /dev/null`,
+        { stdio: 'ignore' },
+      );
+    } catch {
+      /* 清理失败不影响结论 */
+    }
+  }
+
+  const passed = results.filter((r) => r.ok).length;
+  console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
+  console.log(passed === results.length ? '✅ 文件台账链路完整' : '❌ 存在问题,见上');
+  console.log(`截图:${OUT}`);
+  process.exit(passed === results.length ? 0 : 1);
+}
+
+main().catch((e) => {
+  console.error('失败:', e instanceof Error ? e.message : e);
+  finish();
+});

+ 28 - 0
scripts/verify-live-switch.mjs

@@ -0,0 +1,28 @@
+/** 验证:运行中改变窗口宽度会自动切换端 */
+import { chromium } from 'playwright';
+import { loadDevLogin } from './dev-login.mjs';
+const login = loadDevLogin();
+const b = await chromium.launch({ headless: true, channel: 'chromium' });
+const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+const p = await ctx.newPage();
+await p.addInitScript((d) => {
+  localStorage.setItem('fmode_session_token', d.token);
+  localStorage.setItem('fmode_me', JSON.stringify(d.me));
+}, login.payload);
+await p.goto('http://localhost:4200/', { waitUntil: 'domcontentloaded' });
+await p.waitForTimeout(5000);
+console.log('初始(1440):', p.url().replace('http://localhost:4200',''), p.url().includes('/desktop/') ? '✅' : '❌');
+
+// 缩窄 → 应自动跳移动端
+await p.setViewportSize({ width: 600, height: 900 });
+await p.waitForTimeout(3000);
+const u1 = p.url();
+console.log('缩窄到 600:', u1.replace('http://localhost:4200',''), u1.includes('/mobile/') ? '✅ 自动切换' : '❌ 未切换');
+
+// 再变宽 → 应自动跳回桌面
+await p.setViewportSize({ width: 1400, height: 900 });
+await p.waitForTimeout(3000);
+const u2 = p.url();
+console.log('放宽到 1400:', u2.replace('http://localhost:4200',''), u2.includes('/desktop/') ? '✅ 自动切回' : '❌ 未切回');
+
+await b.close();

+ 104 - 0
scripts/verify-mobile-composer.mjs

@@ -0,0 +1,104 @@
+/**
+ * 移动端输入框高度复位验证(2026-11-10 用户反馈)
+ *
+ * 现象:发文件 / 发多行文本后,输入框保持撑开的高度不恢复。
+ *
+ * 用法:`node scripts/verify-mobile-composer.mjs`
+ */
+import { chromium } from 'playwright';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { loadDevLogin } from './dev-login.mjs';
+
+const OUT = resolve('test-results/mobile-composer-20261110');
+mkdirSync(OUT, { recursive: true });
+
+const login = loadDevLogin();
+if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`);
+
+const results = [];
+const check = (n, ok, d = '') => {
+  results.push({ n, ok });
+  console.log(`${ok ? '✅' : '❌'} ${n}${d ? `  (${d})` : ''}`);
+};
+
+const FILE = resolve('/tmp', `m-composer-${Date.now()}.md`);
+writeFileSync(FILE, `# 移动端测试\n\n${Date.now()}\n`);
+
+const b = await chromium.launch({ headless: true, channel: 'chromium' });
+const ctx = await b.newContext({
+  viewport: { width: 390, height: 844 },
+  isMobile: true,
+  hasTouch: true,
+});
+const page = await ctx.newPage();
+await page.addInitScript((d) => {
+  localStorage.setItem('fmode_session_token', d.token);
+  localStorage.setItem('fmode_me', JSON.stringify(d.me));
+}, login.payload);
+
+await page.goto('http://localhost:4200/mobile/', { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(6000);
+await page.screenshot({ path: `${OUT}/01-mobile-home.png` });
+
+/* 进入一个会话 */
+const item = page.locator('.m-session, .session-item, [data-session-id]').first();
+if ((await item.count()) > 0) await item.click({ timeout: 8000 }).catch(() => undefined);
+else await page.getByText(/写一份团队周报/).first().click({ timeout: 8000 }).catch(() => undefined);
+await page.waitForTimeout(5000);
+await page.screenshot({ path: `${OUT}/02-chat.png` });
+
+const ta = page.locator('textarea').first();
+check('① 找到输入框', (await ta.count()) > 0);
+if ((await ta.count()) === 0) await finish();
+
+const baseH = await ta.evaluate((el) => el.getBoundingClientRect().height);
+console.log(`  初始高度: ${baseH.toFixed(1)}px`);
+
+/* 输入多行文本,确认它确实会长高(证明自适应是生效的) */
+await ta.fill('第一行\n第二行\n第三行\n第四行\n第五行');
+await page.waitForTimeout(500);
+const grownH = await ta.evaluate((el) => el.getBoundingClientRect().height);
+check('② 多行输入时输入框会撑高', grownH > baseH + 5, `${baseH.toFixed(0)} → ${grownH.toFixed(0)}px`);
+
+/* 发送,然后看高度是否回落 */
+await ta.press('Enter');
+await page.waitForTimeout(3000);
+const afterH = await ta.evaluate((el) => el.getBoundingClientRect().height);
+check(
+  '③ 发送后输入框高度恢复',
+  Math.abs(afterH - baseH) < 8,
+  `恢复到 ${afterH.toFixed(0)}px(初始 ${baseH.toFixed(0)}px)`,
+);
+await page.screenshot({ path: `${OUT}/03-after-send.png` });
+
+/* 再验一次附件场景 */
+const input = page.locator('input[type="file"]').first();
+if ((await input.count()) > 0) {
+  await ta.fill('附件说明\n第二行\n第三行');
+  await page.waitForTimeout(400);
+  const h2 = await ta.evaluate((el) => el.getBoundingClientRect().height);
+  await input.setInputFiles(FILE);
+  await page.waitForTimeout(1500);
+  await ta.press('Enter');
+  await page.waitForTimeout(8000);
+  const h3 = await ta.evaluate((el) => el.getBoundingClientRect().height);
+  check(
+    '④ 发附件后高度同样恢复',
+    Math.abs(h3 - baseH) < 8,
+    `撑到 ${h2.toFixed(0)}px → 恢复到 ${h3.toFixed(0)}px`,
+  );
+  await page.screenshot({ path: `${OUT}/04-after-file-send.png` });
+} else {
+  check('④ 发附件后高度同样恢复', false, '未找到 file input');
+}
+
+await finish();
+
+async function finish() {
+  const passed = results.filter((r) => r.ok).length;
+  console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
+  console.log(`截图:${OUT}`);
+  await b.close();
+  process.exit(passed === results.length ? 0 : 1);
+}

+ 177 - 0
scripts/verify-real-upload.mjs

@@ -0,0 +1,177 @@
+/**
+ * 真实场景验证:在真实会话里上传文件 → 右侧栏直接显示
+ *
+ * 这是用户要求的核心体验验证:
+ *   「用户上传文件后,右侧栏应该能看到,且**不依赖加载聊天历史**」
+ *
+ * 用法:`node scripts/verify-real-upload.mjs`
+ * 前置:`npm start` 在 4200 运行。
+ */
+import { chromium } from 'playwright';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { execSync } from 'node:child_process';
+import { loadDevLogin } from './dev-login.mjs';
+
+const OUT = resolve('test-results/real-upload-20261110');
+mkdirSync(OUT, { recursive: true });
+
+const login = loadDevLogin();
+if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`);
+const TOKEN = login.payload.token;
+const FN = 'THXAOGKiPK';
+
+const results = [];
+function check(name, ok, detail = '') {
+  results.push({ name, ok });
+  console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `  (${detail})` : ''}`);
+}
+
+async function dbQuery(threadRoot) {
+  const where = encodeURIComponent(JSON.stringify({ threadRoot }));
+  const r = await fetch(
+    `https://server.fmode.cn/parse/classes/AgentDeliverable?where=${where}&limit=50`,
+    {
+      headers: {
+        'X-Parse-Application-Id': 'ncloudmaster',
+        'X-Parse-Master-Key': 'SnkK12*&sunq2#@20!',
+      },
+    },
+  );
+  return (await r.json()).results ?? [];
+}
+
+const FILE_PATH = resolve('/tmp', `real-upload-${Date.now()}.md`);
+writeFileSync(FILE_PATH, `# 真实上传验证\n\n时间:${new Date().toISOString()}\n`);
+
+async function main() {
+  const b = await chromium.launch({ headless: true, channel: 'chromium' });
+  const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+  const page = await ctx.newPage();
+  await page.addInitScript((d) => {
+    localStorage.setItem('fmode_session_token', d.token);
+    localStorage.setItem('fmode_me', JSON.stringify(d.me));
+  }, login.payload);
+
+  /* 记录 listBySession 的调用与返回值,用来判定「右侧栏真的从库里拿」 */
+  const listCalls = [];
+  page.on('response', async (res) => {
+    if (!res.url().includes('/api/functions')) return;
+    try {
+      const req = res.request();
+      const body = JSON.parse(req.postData() ?? '{}');
+      if (body.id !== FN) return;
+      if (body.params?.action !== 'listBySession') return;
+      const json = await res.json();
+      listCalls.push({ threadRoot: body.params.threadRoot, files: json.files ?? [] });
+    } catch {
+      /* 忽略 */
+    }
+  });
+
+  await page.goto('http://localhost:4200/desktop/chat', { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(6000);
+
+  /* 进入第一个真实会话 */
+  const first = page.locator('.session-item, .rail-session, [data-session-id]').first();
+  if ((await first.count()) === 0) {
+    const byText = page.getByText(/写一份团队周报/).first();
+    await byText.click({ timeout: 8000 }).catch(() => undefined);
+  } else {
+    await first.click({ timeout: 8000 }).catch(() => undefined);
+  }
+  await page.waitForTimeout(5000);
+
+  const url = page.url();
+  const threadRoot = decodeURIComponent(url.split('/').pop() ?? '');
+  console.log(`\n当前会话: ${threadRoot}`);
+  await page.screenshot({ path: `${OUT}/01-session-opened.png` });
+
+  check('① 进入了具体会话', !!threadRoot && threadRoot.includes('fmode.cn'), threadRoot);
+
+  /* 走文件选择器上传 */
+  const input = page.locator('input[type="file"]').first();
+  if ((await input.count()) === 0) {
+    check('② 找到上传入口', false, '页面上没有 file input');
+    return finish(b, []);
+  }
+  check('② 找到上传入口', true);
+
+  await input.setInputFiles(FILE_PATH);
+  await page.waitForTimeout(1500);
+  await page.screenshot({ path: `${OUT}/02-file-staged.png` });
+
+  /* 点发送 */
+  const sendBtn = page
+    .locator('button:has(i.fa-paper-plane), button[aria-label*="发送"], .composer button')
+    .last();
+  await sendBtn.click({ timeout: 8000 }).catch(() => undefined);
+  await page.waitForTimeout(12000);
+  await page.screenshot({ path: `${OUT}/03-after-send.png` });
+
+  /* 直接查库:文件是否进了台账,归属是否正确 */
+  const rows = await dbQuery(threadRoot);
+  check(
+    '③ 文件已入库且归属当前会话',
+    rows.length > 0 && rows.every((r) => r.threadRoot === threadRoot),
+    `${rows.length} 条`,
+  );
+  if (rows.length > 0) {
+    const r = rows[0];
+    check(
+      '④ 记录信息完整(上传者/文件名/链接)',
+      r.uploadedBy === 'user' && !!r.fileName && /^https:\/\/s3\.fmode\.cn\//.test(r.artifacts?.[0]?.url ?? ''),
+      `name=${r.fileName}`,
+    );
+    /* 链接可访问 */
+    const head = await fetch(r.artifacts[0].url).then((x) => x.status);
+    check('⑤ 云盘链接可访问', head === 200, `HTTP ${head}`);
+  }
+
+  /* 刷新页面,确认「不依赖聊天历史」也能显示 */
+  await page.reload({ waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(8000);
+  await page.screenshot({ path: `${OUT}/04-after-reload.png`, fullPage: false });
+
+  const lastCall = listCalls.filter((c) => c.threadRoot === threadRoot).pop();
+  check(
+    '⑥ 刷新后右侧栏从数据库拿到了这个文件',
+    !!lastCall && lastCall.files.length > 0,
+    lastCall ? `${lastCall.files.length} 个文件` : '未捕获',
+  );
+
+  /* 页面上真的显示出来了吗 */
+  const panelText = await page
+    .locator('.rail, .session-rail, aside')
+    .first()
+    .innerText()
+    .catch(() => '');
+  const shown = /real-upload|真实上传验证/.test(panelText);
+  check('⑦ 右侧栏界面上能看到这个文件', shown, shown ? '已显示' : `面板文本: ${panelText.slice(0, 60)}`);
+
+  return finish(b, rows);
+}
+
+function finish(browser, rows) {
+  for (const r of rows ?? []) {
+    try {
+      execSync(
+        `curl -s -X DELETE "https://server.fmode.cn/parse/classes/AgentDeliverable/${r.objectId}" ` +
+          `-H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: SnkK12*&sunq2#@20!" -o /dev/null`,
+        { stdio: 'ignore' },
+      );
+    } catch {
+      /* 忽略 */
+    }
+  }
+  const passed = results.filter((x) => x.ok).length;
+  console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
+  console.log(`截图:${OUT}`);
+  browser?.close();
+  process.exit(passed === results.length ? 0 : 1);
+}
+
+main().catch((e) => {
+  console.error('失败:', e instanceof Error ? e.message : e);
+  finish(null, []);
+});

+ 77 - 0
scripts/verify-thread-relation.mjs

@@ -0,0 +1,77 @@
+/**
+ * 验证:App 真实会话发消息时,容器能否读到 threadRoot
+ *
+ * 背景:容器说 HERMES_SESSION_THREAD_ID 是空。
+ * 根因待查:App 的 sendReal 明明传了 sid 作为 threadRootId。
+ *
+ * 本脚本在 App 里真实发一条消息,然后问容器那一回合读到的值。
+ */
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { loadDevLogin } from './dev-login.mjs';
+
+const OUT = resolve('test-results/thread-check-20261110');
+mkdirSync(OUT, { recursive: true });
+
+const login = loadDevLogin();
+if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`);
+
+const b = await chromium.launch({ headless: true, channel: 'chromium' });
+const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+const page = await ctx.newPage();
+await page.addInitScript((d) => {
+  localStorage.setItem('fmode_session_token', d.token);
+  localStorage.setItem('fmode_me', JSON.stringify(d.me));
+}, login.payload);
+
+/* 🔴 抓取 App 真正发出去的 m.room.message content —— 这是判定依据 */
+let sentContent = null;
+page.on('request', (req) => {
+  const u = req.url();
+  if (!u.includes('/send/m.room.message/')) return;
+  try {
+    sentContent = JSON.parse(req.postData() ?? '{}');
+  } catch {
+    /* 忽略 */
+  }
+});
+
+await page.goto('http://localhost:4200/desktop/chat', { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(6000);
+
+/* 点开列表里第一个真实会话(按文本更稳,类名可能变) */
+const byText = page.getByText(/写一份团队周报/).first();
+if ((await byText.count()) > 0) {
+  await byText.click({ timeout: 8000 }).catch(() => undefined);
+} else {
+  const first = page.locator('.session-item').first();
+  if ((await first.count()) > 0) await first.click().catch(() => undefined);
+}
+await page.waitForTimeout(6000);
+
+const url = page.url();
+const sessionId = decodeURIComponent(url.split('/').pop() ?? '');
+console.log(`会话: ${sessionId}`);
+
+const ta = page.locator('textarea').first();
+await ta.waitFor({ timeout: 15000 });
+await ta.fill('请只回一句话:收到。不要调用任何工具。');
+await ta.press('Enter');
+await page.waitForTimeout(4000);
+
+if (sentContent) {
+  const rel = sentContent['m.relates_to'];
+  console.log('\n=== App 发出的 m.room.message content ===');
+  console.log(JSON.stringify(sentContent, null, 2).slice(0, 800));
+  console.log(`\n是否带 m.relates_to: ${rel ? '✅ 有' : '❌ 没有'}`);
+  if (rel) console.log(`  rel_type = ${rel.rel_type}`);
+  if (rel) console.log(`  event_id = ${rel.event_id}`);
+  console.log(`  与当前会话 id 一致: ${rel?.event_id === sessionId ? '✅' : `❌ (${rel?.event_id} vs ${sessionId})`}`);
+} else {
+  console.log('❌ 没抓到发送请求');
+}
+
+await page.screenshot({ path: `${OUT}/01-sent.png` });
+await ctx.close();
+await b.close();

+ 272 - 0
scripts/verify-upload.mjs

@@ -0,0 +1,272 @@
+#!/usr/bin/env node
+/**
+ * 容器文件上云验收(可复用)
+ *
+ * 用法:
+ *   node scripts/verify-upload.mjs                        # 默认 agent-node-lj-test
+ *   node scripts/verify-upload.mjs --agent agent-xxx      # 指定容器
+ *   node scripts/verify-upload.mjs --prompt "自定义需求"   # 自定义提示词
+ *
+ * ## 设计原则(用户裁决 2026-11-10)
+ *
+ * 🔴 提示词**绝不包含**「上传 / 云盘 / 链接 / URL / S3 / 成果」任何字眼。
+ *    一旦提示词里提了,测试就变成「测它听不听话」,而不是「测机制生效没」。
+ *
+ * ## 判定五项
+ *
+ *   ① 回复正文含 https://s3.fmode.cn/   ← 用户唯一能看到的入口
+ *   ② 该链接可访问(HTTP 200 且内容非空)
+ *   ③ 成果表自动新增记录
+ *   ④ 正文不含 /opt/data/ 本地路径
+ *   ⑤ 成果记录里的 name 是原始文件名(不是存储键)
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const FUNCTIONS = 'https://server.fmode.cn/api/functions';
+const FN_PROVISION = 'RP5Ht55NeX';
+const FN_DELIVERABLES = 'lfYlgU7SkK';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function arg(name, fallback) {
+  const i = process.argv.indexOf(`--${name}`);
+  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
+}
+
+const AGENT = arg('agent', 'agent-node-lj-test');
+/** 默认提示词:纯业务需求,零上传暗示 */
+const PROMPT = arg(
+  'prompt',
+  `帮我写一份下周的团队例会纪要模板,Markdown 格式,包含议程、决议、待办三部分。文件名用 meeting-${Date.now()}.md`,
+);
+const WAIT_MS = Number(arg('wait', '420000'));
+
+function loadToken() {
+  const p = join(homedir(), '.fmode', 'config', 'user.json');
+  const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
+  if (typeof t !== 'string' || !t.startsWith('r:')) {
+    throw new Error(`读不到会话凭据:${p}`);
+  }
+  return t;
+}
+
+const TOKEN = loadToken();
+
+async function callFn(id, params) {
+  const r = await fetch(FUNCTIONS, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id, token: TOKEN, params }),
+  });
+  return r.json();
+}
+
+async function provision(agentId) {
+  const j = await callFn(FN_PROVISION, { agentId });
+  const d = j.result ?? j;
+  if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 200)}`);
+  return d;
+}
+
+async function listDeliverables() {
+  const d = await callFn(FN_DELIVERABLES, { action: 'list', agentId: AGENT, limit: 200 });
+  return d.deliverables ?? d.result?.deliverables ?? [];
+}
+
+const results = [];
+function check(name, ok, detail = '') {
+  results.push({ name, ok });
+  console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `  (${detail})` : ''}`);
+}
+
+async function main() {
+  console.log(`\n=== 容器上云验收 ===`);
+  console.log(`容器    : ${AGENT}`);
+  console.log(`提示词  : ${PROMPT}\n`);
+
+  const before = await listDeliverables();
+  const beforeIds = new Set(before.map((d) => d.objectId ?? d.id));
+  console.log(`测试前成果数: ${before.length}\n`);
+
+  const prov = await provision(AGENT);
+  const agent = await provision(AGENT);
+  const mx = await import('matrix-js-sdk');
+  const client = mx.createClient({
+    baseUrl: prov.homeserver,
+    accessToken: prov.accessToken,
+    userId: prov.userMxId,
+  });
+
+  // 新建临时会话,避免历史消息干扰判定
+  const room = (
+    await client.createRoom({
+      invite: [agent.agentMxId],
+      name: `verify-upload-${Date.now()}`,
+      preset: mx.Preset.TrustedPrivateChat,
+    })
+  ).room_id;
+
+  const hs = prov.homeserver.replace(/\/$/, '');
+  const auth = { Authorization: `Bearer ${prov.accessToken}` };
+
+  for (let i = 0; i < 30; i++) {
+    await sleep(3000);
+    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 ?? {})[agent.agentMxId]) break;
+  }
+
+  const sent = await client.sendEvent(room, 'm.room.message', {
+    msgtype: 'm.text',
+    body: PROMPT,
+    'm.mentions': { user_ids: [agent.agentMxId] },
+  });
+
+  const seen = new Set([sent.event_id]);
+  const texts = [];
+  const deadline = Date.now() + WAIT_MS;
+  let url = null;
+
+  while (Date.now() < deadline) {
+    await sleep(8000);
+    try {
+      const r = await fetch(
+        `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=60`,
+        { headers: auth },
+      );
+      const j = await r.json();
+      for (const ev of (j.chunk ?? []).reverse()) {
+        if (ev.type !== 'm.room.message' || seen.has(ev.event_id)) continue;
+        if (ev.sender === prov.userMxId) continue;
+        seen.add(ev.event_id);
+        texts.push(String(ev.content?.body ?? ''));
+      }
+      const all = texts.join('\n');
+      const m = all.match(/https:\/\/s3\.fmode\.cn\/[^\s)"'<>]+/);
+      if (m) {
+        url = m[0].replace(/[.,;:。,;]+$/, '');
+        break;
+      }
+    } catch {
+      /* 轮询抖动忽略 */
+    }
+  }
+
+  const prose = texts.filter((b) => !/^[*·•]?\s*(🐍|💻|🔎|📖|✍️)/.test(b.trim()));
+  console.log('=== 容器最终回复 ===');
+  console.log((prose[prose.length - 1] ?? '(无回复)').slice(0, 900));
+  console.log('');
+
+  /*
+   * 🔴 检查**所有**云盘链接,不只是第一个(2026-11-10 实测踩坑)。
+   *
+   * 容器曾自编一个形如 `https://s3.fmode.cn/user/<uid>/report/xxx.md` 的假链接
+   * 放在正文前面,真实链接在后面。用户点第一个 → 404。
+   *
+   * 所以:抓出全部、逐个验证,任何一个不可访问都算失败。
+   */
+  const allText = texts.join('\n');
+  const allUrls = [...new Set((allText.match(/https:\/\/s3\.fmode\.cn\/[^\s)"'<>`]+/g) ?? []))]
+    .map((u) => u.replace(/[.,;:。,;]+$/, ''));
+
+  if (allUrls.length === 0) {
+    check('① 回复正文含 https 链接', false, '未找到');
+    check('② 全部链接可访问', false, '无链接可验证');
+    console.log('\n--- 诊断 ---');
+    const anyUrl = allText.match(/https?:\/\/[^\s)"'<>`]+/);
+    console.log(`任意 URL     : ${anyUrl ? anyUrl[0] : '(无)'}`);
+    console.log(`含本地路径   : ${/\/opt\/data\//.test(allText) ? '是(未脱敏)' : '否'}`);
+    console.log(`收到消息数   : ${texts.length}`);
+  } else {
+    check('① 回复正文含 https 链接', true, `${allUrls.length} 个`);
+
+    // 逐个验证,统计坏链
+    const bad = [];
+    for (const u of allUrls) {
+      try {
+        const r = await fetch(u);
+        const body = await r.text();
+        if (!r.ok || body.trim().length === 0) bad.push(`${u} → HTTP ${r.status}`);
+      } catch (e) {
+        bad.push(`${u} → ${e instanceof Error ? e.message : '请求失败'}`);
+      }
+    }
+    check(
+      '② 全部链接可访问',
+      bad.length === 0,
+      bad.length === 0 ? `${allUrls.length}/${allUrls.length} 个正常` : `坏链 ${bad.length} 个:${bad.join(' | ')}`,
+    );
+
+    /*
+     * ②b:坏链是否出现在**真实链接之前** —— 用户会先点的那个。
+     * 这是最伤体验的情况:第一个链接就是 404。
+     */
+    if (bad.length > 0) {
+      const first = allUrls[0];
+      const firstBad = bad.some((b) => b.startsWith(first));
+      console.log(
+        firstBad
+          ? `   ⚠️ 第一个链接就是坏链:${first}`
+          : `   第一个链接可用:${first}`,
+      );
+    }
+    url = allUrls.find((u) => /\/deliverables\//.test(u)) ?? allUrls[allUrls.length - 1];
+  }
+
+  await sleep(8000);
+  const after = await listDeliverables();
+  const fresh = after.filter((d) => !beforeIds.has(d.objectId ?? d.id));
+  check('③ 成果表自动新增', fresh.length > 0, `+${fresh.length} 条`);
+
+  check('④ 正文不含本地路径', !/\/opt\/data\//.test(prose.slice(-1).join('')), '');
+
+  const named = fresh.filter((d) =>
+    (d.artifacts ?? []).some(
+      (a) => typeof a === 'object' && a.name && !/^\d+-[a-z0-9]+-/i.test(a.name),
+    ),
+  );
+  check('⑤ 成果 name 为原始文件名', fresh.length === 0 || named.length > 0,
+    fresh.length ? `${named.length}/${fresh.length} 条合格` : '无新成果可验证');
+
+  /*
+   * ⑥ 成果表未被系统文件污染(2026-11-10 踩过的坑)。
+   *
+   * 实测曾把容器自己的 /opt/data/skills/fmode-storage/SKILL.md 当成果传上去,
+   * 更糟的是上传成功后还把它从本地删了。这里检查本轮新增里有没有系统文件。
+   */
+  const SYSTEM_HINTS = [
+    /\/opt\/data\/(skills|git-repos|scripts|plugins|memories|cron|cache|profiles)\//i,
+    /(^|\/)(SKILL|SOUL|MEMORY|USER|AGENTS)\.md$/i,
+    /(^|\/)\.env$/i,
+    /(^|\/)config\.(ya?ml|toml|json)$/i,
+  ];
+  const polluted = fresh.filter((d) =>
+    (d.artifacts ?? []).some((a) => {
+      const src = typeof a === 'object' ? String(a.source ?? '') : '';
+      const nm = typeof a === 'object' ? String(a.name ?? '') : String(a);
+      return SYSTEM_HINTS.some((re) => re.test(src) || re.test(nm));
+    }),
+  );
+  check(
+    '⑥ 成果表未被系统文件污染',
+    polluted.length === 0,
+    polluted.length ? polluted.map((d) => d.title).join(', ') : '',
+  );
+
+  const passed = results.filter((r) => r.ok).length;
+  console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
+  console.log(passed === results.length ? '✅ 上云链路完整' : '❌ 存在问题,见上');
+
+  client.stopClient();
+  process.exit(passed === results.length ? 0 : 1);
+}
+
+main().catch((e) => {
+  console.error('失败:', e instanceof Error ? e.message : e);
+  process.exit(2);
+});

+ 6 - 13
src/app/app.routes.ts

@@ -100,9 +100,8 @@ function segmentsOf(d: RedirectData): string[] {
  * 存量为此踩过 NG04014 的坑,此处刻意规避。
  */
 function normalize(url: string): string {
-  // 惰性读取,避免在路由定义期触碰 localStorage
-  const forced = readModeOverride();
-  const mode = forced ?? detectMode();
+  // 纯自动识别(2026-11-10 去掉手动偏好)
+  const mode = detectMode();
   const clean = url.split('?')[0];
   const [head, id] = clean.split('/').filter(Boolean);
 
@@ -132,19 +131,13 @@ function normalize(url: string): string {
   }
 }
 
-function readModeOverride(): 'desktop' | 'mobile' | null {
-  try {
-    const v = localStorage.getItem('fmode_ui_mode_override');
-    return v === 'desktop' || v === 'mobile' ? v : null;
-  } catch {
-    return null;
-  }
-}
-
 /**
- * 登录页该加载哪个组件。
+ * 登录页 / 等待页该加载哪个组件。
  *
  * 🔴 2026-11-10:改为复用 `DeviceService` 的纯函数 —— 这里原本是一份
  *    逐字复制的 UA 判定,两处一旦不同步就会出现「登录页判移动、入口判桌面」。
+ *
+ * 合并口径(2026-11-11):`detectMode` 的**判定内容**取本项目侧的
+ *   「视口宽度优先 + 去手动偏好」,**写法**取 master 侧的单函数复用。
  */
 const detectMode = detectUiMode;

+ 26 - 2
src/app/app.ts

@@ -3,6 +3,8 @@ import { RouterOutlet } from '@angular/router';
 import { PaletteService } from './core/palette.service';
 import { installDataApi } from './core/data-tools';
 import { UiMToast } from './ui/mobile/components/toast/toast';
+import { AlertHostComponent } from './components/feedback/alert-host/alert-host';
+import { AlertService } from './core/alert.service';
 /**
  * 应用根组件
  *
@@ -11,19 +13,41 @@ import { UiMToast } from './ui/mobile/components/toast/toast';
  */
 @Component({
   selector: 'app-root',
-  imports: [RouterOutlet, UiMToast],
+  imports: [RouterOutlet, UiMToast, AlertHostComponent],
   changeDetection: ChangeDetectionStrategy.OnPush,
   template: `
     <router-outlet />
     <ui-m-toast position="top" />
+    <!--
+      全局异常提示(2026-11-10):不自动消失,必须用户手动关闭。
+      与上面的轻提示分工不同 —— 轻提示 2 秒消失,这里承载「请求异常」这类
+      必须让人看见、知道「这条没被处理」的情况。
+    -->
+    <app-alert-host />
   `,
 })
 export class App {
   private readonly palette = inject(PaletteService);
+  private readonly alert = inject(AlertService);
+
   constructor() {
     // 在首帧前把已保存的配色写到 <html data-palette>,避免闪白/闪色
     this.palette.init();
     /* 开发期数据维护入口(生产构建不暴露):__fmodeData.clearDemo() 等 */
-    if (isDevMode()) installDataApi();
+    if (isDevMode()) {
+      installDataApi();
+      /*
+       * 预览全局异常提示(2026-11-10,仅开发期)。
+       * 真实超时要等 3 分钟,调样式时不可能干等 ——
+       * 控制台执行 `__fmodeData.previewAlert()` 即可立即看到效果。
+       */
+      window.addEventListener('fmode:preview-alert', () => {
+        this.alert.syncState(
+          '容器长时间未响应',
+          true,
+          '最近一条消息已超过 180 秒没有回复。请确认容器是否在线,或重试。',
+        );
+      });
+    }
   }
 }

+ 10 - 2
src/app/components/domain/assistant-turn/assistant-turn.spec.ts

@@ -189,7 +189,7 @@ describe('AssistantTurnComponent', () => {
     expect(segs[1].querySelector('.seg-name')?.textContent).toContain('乙');
   });
 
-  it('无正文且已结束时显示「未响应」(在段内,不在卡级)', () => {
+  it('🔴 无正文且已结束时不渲染任何占位(2026-11-10 改为全局提示)', () => {
     const t = turn();
     // 2026-11-10:块就是渲染单元,块自己的正文也要清空(块与回合口径一致)
     t.blocks[0] = { ...t.blocks[0], body: [], steps: [], files: [], live: false, empty: true, merged: null };
@@ -197,7 +197,8 @@ describe('AssistantTurnComponent', () => {
     t.stepCount = 0;
     render(t);
 
-    expect(fixture.nativeElement.querySelector('.seg-wait')?.textContent).toContain('未响应');
+    // 会话内不再留「未响应」残留,超时由页面层 AlertService 全局提示
+    expect(fixture.nativeElement.querySelector('.seg-wait')).toBeNull();
   });
 
   it('仍在输出且无内容时显示「正在思考…」,且**只出现一次**', () => {
@@ -293,6 +294,13 @@ describe('AssistantTurnComponent', () => {
     expect(fixture.nativeElement.querySelector('.head-phase')?.textContent).toContain('输出中');
   });
 
+  it('🔴 只有过程、没有交付 → 显示「已结束 · 无交付」而不是「已完成」', () => {
+    render(turn({ phase: 'progressOnly', live: false }));
+    const txt = fixture.nativeElement.querySelector('.head-phase')?.textContent ?? '';
+    expect(txt).toContain('无交付');
+    expect(txt).not.toContain('已完成');
+  });
+
   /*
    * 逐秒读数(2026-11-10 用户要求「一秒一秒叠加」;评审 P1-6 要求组件层锁住)
    */

+ 9 - 2
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -441,6 +441,9 @@ export class AssistantTurnComponent implements OnDestroy {
         return '输出中';
       case 'done':
         return '已完成';
+      case 'progressOnly':
+        // 容器干了一轮活,但没产出可交付内容(2026-11-10)
+        return '已结束 · 无交付';
       case 'failed':
         return '失败';
       default:
@@ -655,7 +658,6 @@ export class AssistantTurnComponent implements OnDestroy {
   protected readonly segments = computed(() => {
     const t = this.turn();
     const shown = this.shownMap();
-    const isMulti = this.grouped() && t.blocks.length > 1;
 
     const out: {
       key: string;
@@ -680,7 +682,12 @@ export class AssistantTurnComponent implements OnDestroy {
        * 回合级的合并与流式推进,块只是把内容分段呈现。
        */
       const src = b.merged ?? (t.blocks.length === 1 ? t.merged : null);
-      if (!src && !b.live && !b.empty) continue;
+      /*
+       * 🔴 **已结束且什么都没有**的块不渲染占位(合并口径,2026-11-10):
+       * 空占位只属于「还在等待 / 还在输出」。超时由全局提示告知,
+       * 会话内不留一个转圈的「未响应」残留。
+       */
+      if (!src && !b.live) continue;
 
       let message: Message | null = null;
       let streaming = false;

+ 210 - 0
src/app/components/feedback/alert-host/alert-host.ts

@@ -0,0 +1,210 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { AlertService } from '../../../core/alert.service';
+
+/**
+ * 全局异常提示容器(2026-11-10)。
+ *
+ * 挂在应用根组件上,渲染 `AlertService` 里的提示。
+ * **不会自动消失**,每条都带「我知道了」按钮由用户手动关闭。
+ *
+ * 层级用底座 `--z-toast`(通知层,永远最高),与设计令牌一致。
+ */
+@Component({
+  selector: 'app-alert-host',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    @if (svc.alerts().length > 0) {
+      <div class="alert-stack" role="alert" aria-live="assertive">
+        @for (a of svc.alerts(); track a.id) {
+          <div class="alert" [class.warn]="a.tone === 'warning'">
+            <i
+              class="fa-solid"
+              [class]="a.tone === 'warning' ? 'fa-triangle-exclamation' : 'fa-circle-exclamation'"
+              aria-hidden="true"
+            ></i>
+            <div class="alert-body">
+              <p class="alert-title">{{ a.title }}</p>
+              @if (a.detail) {
+                <p class="alert-detail">{{ a.detail }}</p>
+              }
+            </div>
+            <button type="button" class="alert-close" (click)="svc.dismiss(a.id)">
+              我知道了
+            </button>
+          </div>
+        }
+      </div>
+    }
+  `,
+  styles: `
+    @use '../../../core/styles/breakpoints' as bp;
+
+    /*
+     * ⚠️ CSS 顺序铁律:**基础样式在前,@media 覆盖在后**。
+     *    第一版把 @media 写在 .alert 之前,结果 .alert 的 display:flex
+     *    反把媒体查询里的 display:grid 覆盖掉,grid-area 全部失效,
+     *    文字被挤成一列竖排(用户截图实测)。改样式时务必保持这个顺序。
+     */
+    :host {
+      position: fixed;
+      top: var(--sp-4);
+      left: 50%;
+      transform: translateX(-50%);
+      z-index: var(--z-toast);
+      pointer-events: none;
+    }
+
+    .alert-stack {
+      display: flex;
+      flex-direction: column;
+      gap: var(--sp-2);
+      width: min(92vw, 520px);
+    }
+
+    .alert {
+      display: flex;
+      align-items: flex-start;
+      gap: var(--sp-3);
+      padding: var(--sp-3) var(--sp-4);
+      border: 1px solid var(--danger);
+      border-radius: var(--r-lg);
+      background: var(--surface);
+      box-shadow: var(--shadow-overlay);
+      color: var(--ink);
+      pointer-events: auto;
+      animation: alert-in var(--motion-base) var(--ease-standard);
+    }
+
+    .alert.warn {
+      border-color: var(--warning);
+    }
+
+    .alert > i {
+      flex-shrink: 0;
+      margin-top: 2px;
+      color: var(--danger);
+      font-size: var(--fs-lg);
+    }
+
+    .alert.warn > i {
+      color: var(--warning);
+    }
+
+    .alert-body {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .alert-title {
+      margin: 0;
+      font-size: var(--fs-md);
+      font-weight: 600;
+      line-height: 1.4;
+    }
+
+    .alert-detail {
+      margin: var(--sp-1) 0 0;
+      font-size: var(--fs-sm);
+      color: var(--ink-2);
+      line-height: 1.5;
+    }
+
+    /* 触达区 ≥44px(可访问性底线) */
+    .alert-close {
+      flex-shrink: 0;
+      min-height: 44px;
+      padding: 0 var(--sp-3);
+      border: 1px solid var(--edge-strong);
+      border-radius: var(--r-md);
+      background: var(--surface-2);
+      color: var(--ink);
+      font-size: var(--fs-sm);
+      cursor: pointer;
+      white-space: nowrap;
+      transition: background-color var(--motion-fast) var(--ease-standard);
+    }
+
+    .alert-close:hover {
+      background: var(--surface-hover);
+    }
+
+    @keyframes alert-in {
+      from {
+        opacity: 0;
+        transform: translateY(-8px);
+      }
+      to {
+        opacity: 1;
+        transform: translateY(0);
+      }
+    }
+
+    /* 尊重系统「减少动态效果」设置 */
+    @media (prefers-reduced-motion: reduce) {
+      .alert {
+        animation: none;
+      }
+    }
+
+    /*
+     * ===== 移动端独立样式(2026-11-10 用户裁决)=====
+     * 「不能和 PC 端一致,需要单独优化」。
+     *
+     * 移动端特点:
+     *   · 屏窄 → 整宽贴顶,留刘海安全区
+     *   · 触屏 → 按钮**整行铺满**、≥48px(手指比鼠标粗)
+     *   · 文字多 → 标题与正文分行,按钮独占一行,不跟文字抢宽度
+     */
+    @include bp.below-mobile {
+      :host {
+        top: 0;
+        left: 0;
+        right: 0;
+        /* 用 inset 显式撑满,避免 :host 宽度塌成内容宽 */
+        bottom: auto;
+        width: 100%;
+        box-sizing: border-box;
+        transform: none;
+        padding-top: max(var(--sp-3), env(safe-area-inset-top));
+        padding-inline: var(--sp-2);
+      }
+
+      .alert-stack {
+        width: 100%;
+        max-width: none;
+        gap: var(--sp-2);
+      }
+
+      .alert {
+        display: flex;
+        flex-wrap: wrap;
+        align-items: flex-start;
+        gap: var(--sp-2) var(--sp-2);
+        padding: var(--sp-3);
+        box-sizing: border-box;
+      }
+
+      .alert-body {
+        flex: 1 1 0;
+        min-width: 0;
+      }
+
+      /* 按钮独占一整行,触屏好点 */
+      .alert-close {
+        flex: 1 1 100%;
+        width: 100%;
+        min-height: 48px;
+        margin-top: var(--sp-1);
+        font-size: var(--fs-md);
+        font-weight: 600;
+      }
+
+      .alert-title {
+        font-size: var(--fs-lg);
+      }
+    }
+  `,
+})
+export class AlertHostComponent {
+  protected readonly svc = inject(AlertService);
+}

+ 107 - 0
src/app/core/alert.service.spec.ts

@@ -0,0 +1,107 @@
+import { TestBed } from '@angular/core/testing';
+import { AlertService } from './alert.service';
+
+/**
+ * 全局异常提示(2026-11-10 用户裁决)。
+ *
+ * 核心约束:**不会自动消失**,只能由用户手动关闭。
+ */
+describe('AlertService', () => {
+  let svc: AlertService;
+
+  beforeEach(() => {
+    TestBed.resetTestingModule();
+    TestBed.configureTestingModule({ providers: [AlertService] });
+    svc = TestBed.inject(AlertService);
+  });
+
+  it('show 后进入列表', () => {
+    svc.show('请求异常', '细节');
+    expect(svc.alerts().length).toBe(1);
+    expect(svc.alerts()[0].title).toBe('请求异常');
+    expect(svc.alerts()[0].detail).toBe('细节');
+  });
+
+  it('🔴 不会自动消失(等一段时间仍在)', async () => {
+    svc.show('请求异常');
+    await new Promise((r) => setTimeout(r, 300));
+    expect(svc.alerts().length).toBe(1);
+  });
+
+  it('手动 dismiss 才移除', () => {
+    svc.show('请求异常');
+    const id = svc.alerts()[0].id;
+    svc.dismiss(id);
+    expect(svc.alerts()).toEqual([]);
+  });
+
+  it('同一标题不重复弹(避免每秒 tick 刷屏)', () => {
+    svc.show('请求异常');
+    svc.show('请求异常');
+    svc.show('请求异常');
+    expect(svc.alerts().length).toBe(1);
+  });
+
+  it('不同标题可以并存', () => {
+    svc.show('A');
+    svc.show('B');
+    expect(svc.alerts().length).toBe(2);
+  });
+
+  it('clear 清空全部', () => {
+    svc.show('A');
+    svc.show('B');
+    svc.clear();
+    expect(svc.alerts()).toEqual([]);
+  });
+});
+
+/*
+ * 状态型提示(2026-11-10 用户反馈:不能只弹一次)。
+ *
+ * 约束:当前会话只要存在超时,就一直显示;条件消失后自动撤掉;
+ *      用户手动关掉后不马上重弹,但条件重新出现时要能再提示。
+ */
+describe('AlertService · syncState', () => {
+  let svc: AlertService;
+
+  beforeEach(() => {
+    TestBed.resetTestingModule();
+    TestBed.configureTestingModule({ providers: [AlertService] });
+    svc = TestBed.inject(AlertService);
+  });
+
+  it('条件成立 → 显示', () => {
+    svc.syncState('容器长时间未响应', true, '1 条超时');
+    expect(svc.alerts().length).toBe(1);
+    expect(svc.alerts()[0].detail).toBe('1 条超时');
+  });
+
+  it('🔴 条件持续成立 → 一直显示(不会被去重逻辑吞掉)', () => {
+    svc.syncState('容器长时间未响应', true, '1 条超时');
+    svc.syncState('容器长时间未响应', true, '2 条超时');
+    expect(svc.alerts().length).toBe(1);
+    expect(svc.alerts()[0].detail).toBe('2 条超时'); // 计数实时更新
+  });
+
+  it('条件消失 → 自动撤掉', () => {
+    svc.syncState('容器长时间未响应', true);
+    svc.syncState('容器长时间未响应', false);
+    expect(svc.alerts()).toEqual([]);
+  });
+
+  it('🔴 用户手动关掉后不马上重弹(避免"关不掉")', () => {
+    svc.syncState('容器长时间未响应', true);
+    svc.dismiss(svc.alerts()[0].id);
+    svc.syncState('容器长时间未响应', true);
+    expect(svc.alerts()).toEqual([]);
+  });
+
+  it('🔴 条件消失再出现后,重新提示(问题二次发生要能看见)', () => {
+    svc.syncState('容器长时间未响应', true);
+    svc.dismiss(svc.alerts()[0].id);
+    svc.syncState('容器长时间未响应', false); // 问题消失,解除锁定
+    svc.syncState('容器长时间未响应', true); // 再次超时
+    expect(svc.alerts().length).toBe(1);
+  });
+});

+ 124 - 0
src/app/core/alert.service.ts

@@ -0,0 +1,124 @@
+import { Injectable, signal } from '@angular/core';
+
+/**
+ * 全局异常提示(业务层,2026-11-10)。
+ *
+ * ## 为什么不用底座 toast
+ *
+ * `src/app/ui/**` 是 ui-kit 官方底座,**项目规则禁止修改**。
+ * 而底座的 `UiMToastService` 是「固定自动消失」,无法表达
+ * 「用户必须手动关闭」这个语义。所以这里在**业务层**另起一个轻量服务,
+ * 只承载「需要人工确认的异常」,与普通 toast 分工不同:
+ *
+ * | 类型 | 用什么 | 行为 |
+ * |---|---|---|
+ * | 普通轻提示(已保存/已复制) | 底座 `UiMToastService` | 2 秒自动消失 |
+ * | **异常提示(请求超时等)** | 本服务 | **不自动消失,必须点关闭** |
+ *
+ * ## 为什么必须手动关闭
+ *
+ * 用户裁决原话:「应该给出全局提示说请求异常,并且这个全局提示需要手动关闭
+ * 才会关闭,不会自动隐藏。」——容器超时意味着这条消息**根本没被处理**,
+ * 一闪而过的提示会让用户以为已经正常了。
+ */
+export interface AppAlert {
+  id: number;
+  /** 标题,一句话说清发生了什么 */
+  title: string;
+  /** 补充说明(可选),告诉用户能做什么 */
+  detail?: string;
+  /** 级别:异常用 error,提醒用 warning */
+  tone: 'error' | 'warning';
+  /**
+   * 是否由「当前状态」驱动(2026-11-10)。
+   *
+   * 状态型提示(如「容器未响应」):条件成立时存在,条件消失后自动撤掉。
+   * 用户手动关闭后**不会马上重弹**,要等条件先消失再出现才会重新提示 ——
+   * 否则用户会觉得「关不掉」。
+   */
+  stateDriven?: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class AlertService {
+  readonly alerts = signal<AppAlert[]>([]);
+  private nextId = 1;
+
+  /**
+   * 用户关掉过的状态型标题**本轮**不再弹(2026-11-10)。
+   *
+   * 🔴 用户裁决:超时提示**每次超时都要弹**。
+   *    但同一轮超时里,用户点「我知道了」之后不能每秒又冒出来 ——
+   *    所以记一下,等「本轮超时结束」(条件变 false)时清除,
+   *    下一次发生新的超时会**重新弹出**。
+   */
+  private readonly _dismissedThisRound = new Set<string>();
+
+  /**
+   * 弹出一条**不会自动消失**的提示。
+   * 同一 message 已存在时不重复弹(避免每秒 tick 刷屏)。
+   */
+  show(title: string, detail?: string, tone: 'error' | 'warning' = 'error'): void {
+    if (this.alerts().some((a) => a.title === title)) {
+      // 已存在:只更新说明(计数可能变化),不新增
+      this.alerts.update((list) =>
+        list.map((a) => (a.title === title && a.detail !== detail ? { ...a, detail } : a)),
+      );
+      return;
+    }
+    this.alerts.update((list) => [...list, { id: this.nextId++, title, detail, tone }]);
+  }
+
+  /**
+   * 状态型提示(2026-11-10):条件成立就确保在,条件消失就撤掉。
+   *
+   * @param active 当前条件是否成立
+   */
+  syncState(title: string, active: boolean, detail?: string): void {
+    if (active) {
+      /*
+       * 用户刚关掉 → 本轮不再弹(否则每秒都会重新出现,关不掉)。
+       * 但「本轮」只指这次超时:条件一旦消失就清空,下次超时会重新提示。
+       */
+      if (this._dismissedThisRound.has(title)) return;
+      const exists = this.alerts().some((a) => a.title === title);
+      if (exists) {
+        this.alerts.update((list) =>
+          list.map((a) => (a.title === title && a.detail !== detail ? { ...a, detail } : a)),
+        );
+      } else {
+        this.alerts.update((list) => [
+          ...list,
+          { id: this.nextId++, title, detail, tone: 'error', stateDriven: true },
+        ]);
+      }
+      return;
+    }
+    // 条件已消失:撤掉提示,并解除本轮锁定 —— 下次超时会重新提示
+    this._dismissedThisRound.delete(title);
+    this.alerts.update((list) => list.filter((a) => a.title !== title));
+  }
+
+  /** 用户点关闭 */
+  dismiss(id: number): void {
+    const hit = this.alerts().find((a) => a.id === id);
+    if (hit?.stateDriven) this._dismissedThisRound.add(hit.title);
+    this.alerts.update((list) => list.filter((a) => a.id !== id));
+  }
+
+  /** 清空全部 */
+  clear(): void {
+    this.alerts.set([]);
+    this._dismissedThisRound.clear();
+  }
+
+  /**
+   * 解除「用户已关闭」的锁(2026-11-10)。
+   *
+   * 用于「新的超时发生」时:用户上一次关掉了提示,但新的问题又出现了,
+   * 必须重新提示 —— 用户裁决「每次超时都要弹」。
+   */
+  resetDismiss(title: string): void {
+    this._dismissedThisRound.delete(title);
+  }
+}

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

@@ -6,8 +6,10 @@ import {
   ChatThreadUpsert,
   Deliverable,
   DeliverableGroup,
+  FileEntry,
   MatrixProvision,
   Me,
+  SessionFile,
   OrgUser,
   OrgMembership,
   OrgUserPage,
@@ -135,6 +137,23 @@ export abstract class FmodeApi {
   /** 按项目分组(`action:'byProject'`) */
   abstract fetchDeliverablesByProject(limit?: number): Promise<DeliverableGroup[]>;
 
+  // ---------- 文件台账(云函数 fmode-file-hub,2026-11-10) ----------
+
+  /**
+   * 登记一条文件记录(用户上传 / 容器产出共用)。
+   *
+   * 幂等:同一 (用户, 会话, URL) 重复登记返回已有 id,不产生重复。
+   */
+  abstract registerFile(entry: FileEntry): Promise<{ id: string; duplicated?: boolean }>;
+
+  /**
+   * 按会话拉文件(右侧栏主路径)。
+   *
+   * 用户要求:每次直接从数据库加载,不依赖加载聊天历史。
+   * 历史数据没有 threadRoot,查不到属预期(用户裁决:只保证新数据)。
+   */
+  abstract fetchFilesBySession(threadRoot: string, limit?: number): Promise<SessionFile[]>;
+
   // ---------- 任务看板(FR-07b) ----------
 
   /**

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

@@ -15,8 +15,10 @@ import {
   ChatThreadUpsert,
   Deliverable,
   DeliverableGroup,
+  FileEntry,
   MatrixProvision,
   Me,
+  SessionFile,
   OrgMembership,
   OrgUser,
   OrgUserPage,
@@ -154,6 +156,39 @@ export class MockApi extends FmodeApi {
     return groups;
   }
 
+  // ---------- 文件台账(演示态:内存存储,行为与云端一致) ----------
+
+  /** 演示态文件台账:会话 id → 文件列表 */
+  private readonly mockFiles = new Map<string, SessionFile[]>();
+  private mockFileSeq = 1;
+
+  async registerFile(entry: FileEntry): Promise<{ id: string; duplicated?: boolean }> {
+    const list = this.mockFiles.get(entry.threadRoot) ?? [];
+    const hit = list.find((f) => f.url === entry.url);
+    if (hit) return { id: hit.id, duplicated: true };
+    const id = `mock-file-${this.mockFileSeq++}`;
+    list.unshift({
+      id,
+      threadRoot: entry.threadRoot,
+      agentId: entry.agentId ?? '',
+      roomId: entry.roomId ?? '',
+      name: entry.name,
+      size: entry.size ?? -1,
+      mimeType: entry.mimeType ?? '',
+      url: entry.url,
+      storageKey: entry.key ?? '',
+      uploadedBy: entry.uploadedBy,
+      status: 'delivered',
+      deliveredAt: Date.now(),
+    });
+    this.mockFiles.set(entry.threadRoot, list);
+    return { id };
+  }
+
+  async fetchFilesBySession(threadRoot: string, limit = 100): Promise<SessionFile[]> {
+    return (this.mockFiles.get(threadRoot) ?? []).slice(0, limit);
+  }
+
   // ---------- 会话目录(演示态:内存存储,行为与云端一致) ----------
 
   /**

+ 43 - 0
src/app/core/api/models.ts

@@ -306,6 +306,49 @@ export interface Artifact {
 
 export type DeliverableState = 'delivered' | 'failed';
 
+/**
+ * 文件台账条目(云函数 fmode-file-hub,2026-11-10)
+ *
+ * 用户裁决:消息不入库,只有**文件与成果**入库,让右侧栏能按会话直接加载。
+ * 只存访问链接与元信息,不存文件内容。
+ */
+export interface FileEntry {
+  /** 会话归属(App 的会话 id = Matrix thread 根),核心字段 */
+  threadRoot: string;
+  /** 产出的容器;用户上传的文件也要带,便于按容器聚合 */
+  agentId?: string;
+  roomId?: string;
+  name: string;
+  /** 云盘访问链接(https,必须是 s3.fmode.cn / file-cloud.fmode.cn) */
+  url: string;
+  /** 云盘对象键,url 只是它的投影 */
+  key?: string;
+  mimeType?: string;
+  size?: number;
+  /** 谁放进来的:用户自己传的,还是容器产出的 */
+  uploadedBy: 'user' | 'agent';
+  /** 容器内的原始路径(仅容器产出时有) */
+  source?: string;
+}
+
+/** 从文件台账拉回的一条记录(服务端已归一化) */
+export interface SessionFile {
+  id: string;
+  threadRoot: string;
+  agentId: string;
+  roomId: string;
+  name: string;
+  /** 未知大小用 -1(与云函数口径一致) */
+  size: number;
+  mimeType: string;
+  url: string;
+  storageKey: string;
+  uploadedBy: 'user' | 'agent';
+  status: string;
+  /** epoch 毫秒 */
+  deliveredAt: number;
+}
+
 export interface Deliverable {
   id: string;
   agentId: string;

+ 179 - 0
src/app/core/api/parse-api.spec.ts

@@ -401,6 +401,75 @@ describe('ParseApi', () => {
       expect(d.tags).toEqual([]);
     });
 
+    /*
+     * 显示名与存储键分离(2026-11-10 用户裁决)。
+     *
+     * 中文文件名进不了签名 URL,所以存储键由服务端生成(含随机段)。
+     * 展示名必须来自 `name` 字段,而不是从 url 末段硬取。
+     */
+    it('🔴 有 name 字段时,展示名用 name 而不是存储键末段', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            deliverables: [
+              {
+                objectId: '1',
+                title: '周报',
+                artifacts: [
+                  {
+                    name: '新同事入职清单.md',
+                    url: 'https://s3.fmode.cn/deliverables/a/202611/x/0-mwa2gx2gog-abc123.md',
+                    type: 'md',
+                  },
+                ],
+              },
+            ],
+          },
+        },
+      ]);
+      const [d] = await api.fetchDeliverables();
+      expect(d.artifacts[0].name).toBe('新同事入职清单.md');
+    });
+
+    it('没有 name 时从存储键剥掉内部前缀', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            deliverables: [
+              {
+                objectId: '1',
+                title: '周报',
+                artifacts: ['https://s3.fmode.cn/deliverables/a/202611/x/0-mwa2gx2gog-weekly-report.md'],
+              },
+            ],
+          },
+        },
+      ]);
+      const [d] = await api.fetchDeliverables();
+      expect(d.artifacts[0].name).toBe('weekly-report.md');
+    });
+
+    it('纯随机键(无前缀可剥)保持原样,不返回空名', async () => {
+      stubFetch([
+        {
+          body: {
+            code: 200,
+            deliverables: [
+              {
+                objectId: '1',
+                title: 'x',
+                artifacts: ['https://s3.fmode.cn/deliverables/a/202611/x/1757631234567.md'],
+              },
+            ],
+          },
+        },
+      ]);
+      const [d] = await api.fetchDeliverables();
+      expect(d.artifacts[0].name).toBe('1757631234567.md');
+    });
+
     it('agentId 过滤参数被传入 params', async () => {
       stubFetch([{ body: { code: 200, deliverables: [] } }]);
       await api.fetchDeliverables({ agentId: 'a1', limit: 12 });
@@ -472,3 +541,113 @@ describe('ParseApi', () => {
     });
   });
 });
+
+/**
+ * 文件台账(fmode-file-hub,2026-11-10)
+ *
+ * 用户要求:文件与成果入库,右侧栏按会话直接加载,不依赖聊天历史。
+ * 这些用例锁住「请求形状」和「错误处理」,避免悄悄退回静默失败。
+ */
+describe('ParseApi · 文件台账', () => {
+  let api: ParseApi;
+  let calls: { url: string; init: RequestInit }[] = [];
+
+  function stub(responses: { status?: number; body: unknown }[]): void {
+    let i = 0;
+    spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
+      calls.push({ url: String(input), init: init ?? {} });
+      const r = responses[Math.min(i++, responses.length - 1)];
+      const status = r.status ?? 200;
+      return Promise.resolve({
+        ok: status >= 200 && status < 300,
+        status,
+        json: () => Promise.resolve(r.body),
+        text: () => Promise.resolve(JSON.stringify(r.body)),
+      } as unknown as Response);
+    });
+  }
+  const bodyOf = (idx = 0): Record<string, unknown> =>
+    JSON.parse(String(calls[idx].init.body)) as Record<string, unknown>;
+
+  beforeEach(() => {
+    calls = [];
+    api = new ParseApi(() => 'r:session-token-abc');
+  });
+
+  it('registerFile 调用新函数并透传文件信息', async () => {
+    stub([{ body: { code: 200, id: 'file-1' } }]);
+    const r = await api.registerFile({
+      threadRoot: '$approot_x:fmode.cn',
+      agentId: 'agent-node-lj-test',
+      name: 'a.md',
+      url: 'https://s3.fmode.cn/k/a.md',
+      key: 'k/a.md',
+      mimeType: 'text/markdown',
+      size: 12,
+      uploadedBy: 'user',
+    });
+    expect(r.id).toBe('file-1');
+    const b = bodyOf();
+    expect(b['id']).toBe('THXAOGKiPK');
+    const p = b['params'] as Record<string, unknown>;
+    expect(p['action']).toBe('registerFile');
+    expect(p['threadRoot']).toBe('$approot_x:fmode.cn');
+    expect(p['uploadedBy']).toBe('user');
+  });
+
+  it('registerFile 返回 duplicated 时如实透出(幂等不是错误)', async () => {
+    stub([{ body: { code: 200, id: 'file-1', duplicated: true } }]);
+    const r = await api.registerFile({
+      threadRoot: 't',
+      name: 'a.md',
+      url: 'https://s3.fmode.cn/k/a.md',
+      uploadedBy: 'user',
+    });
+    expect(r.duplicated).toBeTrue();
+  });
+
+  it('registerFile 失败必须抛错,不得静默吞掉', async () => {
+    stub([{ body: { code: 400, msg: 'threadRoot required' } }]);
+    await expectAsync(
+      api.registerFile({ threadRoot: '', name: 'a', url: 'https://s3.fmode.cn/a', uploadedBy: 'user' }),
+    ).toBeRejectedWithError(/threadRoot required/);
+  });
+
+  it('fetchFilesBySession 带 threadRoot 查询并还原记录', async () => {
+    stub([
+      {
+        body: {
+          code: 200,
+          files: [
+            {
+              id: 'f1',
+              threadRoot: 't1',
+              agentId: 'a',
+              roomId: 'r',
+              name: 'a.md',
+              size: 12,
+              mimeType: 'text/markdown',
+              url: 'https://s3.fmode.cn/a.md',
+              storageKey: 'k',
+              uploadedBy: 'user',
+              status: 'delivered',
+              deliveredAt: 1700000000000,
+            },
+          ],
+        },
+      },
+    ]);
+    const files = await api.fetchFilesBySession('t1');
+    expect(files.length).toBe(1);
+    expect(files[0].name).toBe('a.md');
+    expect(files[0].uploadedBy).toBe('user');
+    const p = bodyOf()['params'] as Record<string, unknown>;
+    expect(p['action']).toBe('listBySession');
+    expect(p['threadRoot']).toBe('t1');
+  });
+
+  it('fetchFilesBySession 失败抛错(右栏据此显示错误态,不伪装成空)', async () => {
+    stub([{ body: { code: 401, msg: 'invalid session' } }]);
+    await expectAsync(api.fetchFilesBySession('t1')).toBeRejectedWithError(/invalid session/);
+  });
+});

+ 74 - 2
src/app/core/api/parse-api.ts

@@ -15,8 +15,10 @@ import {
   ChannelConfig,
   Deliverable,
   DeliverableGroup,
+  FileEntry,
   MatrixProvision,
   Me,
+  SessionFile,
   OrgMembership,
   OrgUser,
   OrgUserPage,
@@ -72,6 +74,14 @@ const FN = {
   workgroup: 'V1EBgm05y2',
   /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */
   chatThreads: 'oR5O67i1Cm',
+  /**
+   * 文件台账(2026-11-10 新建,Function.name = fmode-file-hub)
+   *
+   * 为什么另起一个:老成果函数 `lfYlgU7SkK` 的 report 用字段白名单写入,
+   * 里面没有 `threadRoot`,容器传了也被静默丢弃,成果无法归属到会话。
+   * 用户裁决:现有云函数一行不动,新建独立函数走新接口。
+   */
+  fileHub: 'THXAOGKiPK',
   /**
    * 组织成员管理(2026-11-10 新建,Function.name = fmodeagent-users)。
    *
@@ -509,6 +519,29 @@ export class ParseApi extends FmodeApi {
     return (d.jobs ?? []).map((x) => x as ArtifactJob);
   }
 
+  // ---------- 文件台账(fmode-file-hub,2026-11-10) ----------
+
+  async registerFile(entry: FileEntry): Promise<{ id: string; duplicated?: boolean }> {
+    const d = await this.callFunction<{ code: number; id?: string; duplicated?: boolean }>(
+      FN.fileHub,
+      { action: 'registerFile', ...entry },
+    );
+    if (d.code !== 200 || !d.id) {
+      throw new Error(pickMessage(d) ?? '文件登记失败');
+    }
+    return { id: d.id, duplicated: d.duplicated };
+  }
+
+  async fetchFilesBySession(threadRoot: string, limit = 100): Promise<SessionFile[]> {
+    const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, {
+      action: 'listBySession',
+      threadRoot,
+      limit,
+    });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '会话文件加载失败');
+    return (d.files ?? []).map((x) => x as SessionFile);
+  }
+
   // ---------- 工作组 ----------
 
   async createWorkGroup(input: {
@@ -1033,6 +1066,35 @@ function mapAgent(raw: Record<string, unknown>): Agent {
   };
 }
 
+/**
+ * 从存储键里还原一个「像文件名」的展示名(2026-11-10)。
+ *
+ * 存储键由服务端生成,常见形态:
+ *   `deliverables/<agentId>/202611/<did>/0-mwa2gx2gog-weekly-report.md`
+ *   `deliverables/<agentId>/202611/<did>/1757631234567-3f2a.md`(雪花/随机段)
+ *
+ * 规则:取末段 → 去掉前导「序号-随机串-」前缀 → 若剩余仍是纯随机串则保持原样。
+ * 目的只是让 UI 不显示成 `0-mwa2gx2gog-weekly-report.md` 这种带内部前缀的样子。
+ */
+function prettifyStorageKey(keyOrUrl: string): string {
+  if (!keyOrUrl) return '';
+  const path = keyOrUrl.split(/[?#]/)[0];
+  const seg = path.split('/').filter(Boolean).pop() ?? '';
+  if (!seg) return '';
+  let name = seg;
+  try {
+    name = decodeURIComponent(seg);
+  } catch {
+    /* 编码坏了就用原样 */
+  }
+  /*
+   * 剥前导 `<数字>-<随机串>-`:服务端在存储键前面加的去重前缀。
+   * 只剥「数字-字母数字-」这两段,后面的原样保留(可能已经是中文名)。
+   */
+  name = name.replace(/^\d+-[a-z0-9]{6,}-/i, '');
+  return name || seg;
+}
+
 /** 成果原始记录 → `Deliverable` 领域模型 */
 function mapDeliverable(raw: Record<string, unknown>): Deliverable {
   const status = String(raw['status'] ?? '');
@@ -1057,8 +1119,9 @@ function mapDeliverable(raw: Record<string, unknown>): Deliverable {
       if (typeof a === 'string') {
         const s = a.trim();
         if (!s) return null;
-        const name = s.split(/[/\\]/).pop() || s;
         const isUrl = /^https?:\/\//i.test(s);
+        // 与对象形态同口径:优先从存储键还原展示名,不直接拿末段当名字
+        const name = prettifyStorageKey(s);
         return {
           name,
           url: isUrl ? s : '',
@@ -1084,8 +1147,17 @@ function mapDeliverable(raw: Record<string, unknown>): Deliverable {
         const url = isUrl ? rawUrl : '';
         const source = isUrl ? undefined : rawUrl || fallbackSource || undefined;
         if (!name && !url && !source) return null;
+        /*
+         * 显示名与存储键**分离**(2026-11-10 用户裁决)。
+         *
+         * 存储键由服务端生成(形如 `0-mwa2gx2gog-weekly-report.md` 或纯随机 ID),
+         * 中文文件名进不了签名 URL,所以**不能**拿 url 末段当显示名。
+         * 优先用 `name` 字段;没有才回退,且回退时剥掉服务端加的前缀段。
+         */
+        const displayName =
+          name || prettifyStorageKey(url) || prettifyStorageKey(source ?? '') || '未命名文件';
         return {
-          name: name || url.split('/').pop() || source?.split('/').pop() || '',
+          name: displayName,
           url,
           type: String(o['type'] ?? ''),
           ...(source ? { source } : {}),

+ 155 - 5
src/app/core/assistant-turn.spec.ts

@@ -1,7 +1,14 @@
 import { GroupBlock, GroupTurn } from './group-turns';
 import { Message } from './api/models';
 import { Turn } from './turn-aggregator';
-import { elapsedSeconds, turnsFromDm, turnsFromGroup, waitText } from './assistant-turn';
+import {
+  elapsedSeconds,
+  isTurnTimedOut,
+  TURN_TIMEOUT_MS,
+  turnsFromDm,
+  turnsFromGroup,
+  waitText,
+} from './assistant-turn';
 
 /**
  * 回合视图模型单测(2026-11-06)
@@ -150,15 +157,67 @@ describe('turnsFromDm(单聊)', () => {
     expect(quiet.live).toBe(false);
   });
 
-  it('耗时按首末消息时间差取整', () => {
+  /*
+   * 耗时口径(2026-11-10 用户裁决):从**用户发出消息**起算。
+   * 原实现是「首末 agent 消息时间差」,会卡住再跳变,已废弃。
+   */
+  it('耗时从用户发消息起算,取首末消息跨度', () => {
     const [t] = turnsFromDm(
-      [dmTurn([msg('a1', 'agent', 'x', 0), msg('a2', 'agent', 'y', 4200)])],
+      [dmTurn([msg('a1', 'agent', 'x', 0), msg('a2', 'agent', 'y', 4200)], { userMessage: undefined })],
       0,
       false,
     );
+    // 无用户消息时回落到首条 agent 消息作锚点
     expect(t.blocks[0].seconds).toBe(4);
   });
 
+  it('live 时按当前时钟稳定递增(elapsedSeconds 逐秒,不再卡住)', () => {
+    const userAt = msg('u1', 'me', '你好', 0);
+    const turn = dmTurn([msg('a1', 'agent', 'x', 2000)], {
+      userMessage: userAt,
+      closed: false,
+    });
+
+    // 第三个参数是 typing —— 容器正在输出,回合保持 live
+    const at10s = turnsFromDm([turn], T0 + 10_000, true)[0].blocks[0];
+    const at15s = turnsFromDm([turn], T0 + 15_000, true)[0].blocks[0];
+
+    // 同一个回合、同一条最后消息,但时钟推进 → 读数必须跟着涨
+    expect(at10s.live).toBe(true);
+    expect(elapsedSeconds(at10s, T0 + 10_000)).toBe(10);
+    expect(elapsedSeconds(at15s, T0 + 15_000)).toBe(15);
+  });
+
+  it('回合结束后冻结,不再随时间增长', () => {
+    const userAt = msg('u1', 'me', '你好', 0);
+    const turn = dmTurn([msg('a1', 'agent', 'x', 8000)], {
+      userMessage: userAt,
+      closed: true,
+    });
+
+    const early = turnsFromDm([turn], T0 + 30_000, false)[0].blocks[0];
+    const later = turnsFromDm([turn], T0 + 600_000, false)[0].blocks[0];
+
+    expect(early.live).toBe(false);
+    // 冻结在「末条消息 - 用户发消息」= 8 秒
+    expect(elapsedSeconds(early, T0 + 30_000)).toBe(8);
+    expect(elapsedSeconds(later, T0 + 600_000)).toBe(8);
+  });
+
+  it('容器排队时间也算进用户等待', () => {
+    const userAt = msg('u1', 'me', '你好', 0);
+    // 用户 0s 发出,容器 40s 才回
+    const turn = dmTurn([msg('a1', 'agent', 'x', 40_000)], {
+      userMessage: userAt,
+      closed: true,
+    });
+
+    const [t] = turnsFromDm([turn], T0 + 60_000, false);
+    expect(elapsedSeconds(t.blocks[0], T0 + 60_000)).toBe(40);
+    /* 容器真实处理耗时从首条 agent 消息起算,与用户等待无关 */
+    expect(t.blocks[0].seconds).toBe(0);
+  });
+
   /**
    * 逐秒读数(2026-11-10 用户反馈:「5 秒、18 秒、30 秒跳」,要一秒一秒加)
    */
@@ -346,8 +405,99 @@ describe('waitText(等待 / 输出占位文案)', () => {
     expect(waitText(20_000, true, false, false)).toBe('');
   });
 
-  it('已结束且什么也没有 → 未响应', () => {
-    expect(waitText(30_000, false, false, true)).toBe('未响应');
+  it('🔴 已结束且什么也没有 → 不再显示「未响应」气泡(2026-11-10 改为全局提示)', () => {
+    expect(waitText(30_000, false, false, true)).toBe('');
+  });
+});
+
+/*
+ * 🔴 2026-11-10:「已完成」必须代表真的有交付。
+ * 原判据「只要不是全空就算 done」,容器只回一串进度也会标「已完成」。
+ */
+describe('phaseOf(交付口径)', () => {
+  const user = () => msg('u1', 'me', '帮我写个文件', 0);
+
+  it('有正文 → done', () => {
+    const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '这是最终答复。', 1000)], { userMessage: user() })], T0 + 60_000, false);
+    expect(t.phase).toBe('done');
+  });
+
+  it('🔴 只有过程步骤、没有正文 → progressOnly', () => {
+    const progress = ['🐍 Running code from hermes_tools import terminal r=t...', '🐍 Running code from hermes_tools import search_files...'].join('\n');
+    const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', progress, 1000)], { userMessage: user() })], T0 + 60_000, false);
+    expect(t.phase).toBe('progressOnly');
+  });
+
+  it('什么都没有 → idle', () => {
+    const [t] = turnsFromDm([dmTurn([], { userMessage: user() })], T0 + 60_000, false);
+    expect(t.phase).toBe('idle');
+  });
+});
+
+/*
+ * 超时未响应判定(2026-11-10 用户裁决:超时改为全局提示)。
+ */
+describe('isTurnTimedOut', () => {
+  const MIN = 60_000;
+
+  it('超过 3 分钟且一个字没回 → 超时', () => {
+    expect(isTurnTimedOut(181_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('🔴 已经有输出 → 不算超时(容器干活慢不等于异常)', () => {
+    expect(isTurnTimedOut(10 * MIN, true, false)).toBe(false);
+  });
+
+  it('🔴 仍在进行中 → 不算超时(可能正在跑长任务)', () => {
+    expect(isTurnTimedOut(10 * MIN, false, true)).toBe(false);
+  });
+});
+
+/*
+ * 🔴 2026-11-10 用户实测:容器不回时界面永远挂「已送达,等待回复…(253s)」,
+ *    超时全局提示永远不弹。根因是 `awaitingFirstReply` 让 `live` 恒为真。
+ */
+describe('未回复回合的超时收尾', () => {
+  const userMsg = () => msg('u1', 'me', '你好', 0);
+
+  /** 用户发了消息,容器一条都没回 */
+  const emptyTurn = (): Turn => dmTurn([], { userMessage: userMsg(), closed: false });
+
+  it('🔴 等待未到 3 分钟 → 仍算 live(正常等待)', () => {
+    const [t] = turnsFromDm([emptyTurn()], T0 + 30_000, false);
+    expect(t.live).toBe(true);
+    expect(t.blocks[0].live).toBe(true);
+  });
+
+  it('🔴 等待超过 3 分钟 → 不再 live,占位文案消失', () => {
+    const [t] = turnsFromDm([emptyTurn()], T0 + TURN_TIMEOUT_MS + 5_000, false);
+    expect(t.blocks[0].live).toBe(false);
+    // 不再是「等待回复…」,而是空(交给全局提示)
+    const text = waitText(
+      T0 + TURN_TIMEOUT_MS + 5_000 - T0,
+      false,
+      t.blocks[0].live,
+      t.blocks[0].empty,
+    );
+    expect(text).toBe('');
+  });
+
+  it('🔴 超过 3 分钟后判定为超时(全局提示依据)', () => {
+    const elapsed = TURN_TIMEOUT_MS + 5_000;
+    const [t] = turnsFromDm([emptyTurn()], T0 + elapsed, false);
+    expect(isTurnTimedOut(elapsed, false, t.blocks[0].live)).toBe(true);
+  });
+
+  it('已经回过内容 → 不受该窗口影响', () => {
+    const reply = msg('a1', 'agent', '这是回复。', 2_000);
+    const t0 = dmTurn([reply], { userMessage: userMsg(), closed: false });
+    const [t] = turnsFromDm([t0], T0 + TURN_TIMEOUT_MS + 5_000, false);
+    expect(t.blocks[0].body.length).toBeGreaterThan(0);
   });
 });
 

+ 101 - 16
src/app/core/assistant-turn.ts

@@ -100,7 +100,7 @@ export interface TurnBlockView {
   waitMs: number;
 }
 
-export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle';
+export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle' | 'progressOnly';
 
 /**
  * 整轮合并后的正文(2026-11-07 用户需求)。
@@ -342,6 +342,20 @@ function proseOf(text: string): boolean {
  *
  * 调用方应优先传 `QUIET_MS`;这里的字面量只是兜底,两处必须同步修改。
  */
+/**
+ * 判定「超时」的时长门槛。
+ *
+ * 用户裁决(2026-11-10 第二次调整):**发出消息后 3 分钟内没有任何回复**才算异常。
+ *
+ * 演进:5 分钟太长(干等)→ 1 分钟太短(容器跑长任务时误报,用户实测被误判)
+ *      → 定为 3 分钟。容器正常回一轮 10~60 秒,但要跑任务时可能几分钟。
+ *
+ * ⚠️ 注意:这个值同时参与 `waitingTooLong`(超时后 `live` 转 false、占位消失)。
+ *    改大意味着占位会多挂一段时间,这是有意的 —— 用户宁可看「正在思考」
+ *    也不愿意 1 分钟就被告知「异常」。
+ */
+export const TURN_TIMEOUT_MS = 180_000;
+
 const DEFAULT_QUIET_MS = 25_000;
 
 function toBlockView(
@@ -380,20 +394,28 @@ function toBlockView(
    */
   const idleFor = now - src.updatedAt;
   const typingAlive = src.typing && idleFor < quietMs * 2;
-  const live = !src.closed && (awaitingFirstReply || typingAlive || idleFor < quietMs);
   /*
-   * 用时读数(2026-11-10 修复,用户要求「一秒一秒叠加」)。
+   * 等待超时收尾(2026-11-10,用户裁决保留):
    *
-   * 🔴 原实现 `seconds = updatedAt - startedAt` 只在容器发新事件那一跳才变,
-   *    用户看到 5s → 18s → 30s 的跳变。现在拆成两个字段:
+   * `awaitingFirstReply` 若恒为真 → `live` 永远 true → 界面永远挂着
+   * 「已送达,等待回复…(253s)」,`isTurnTimedOut` 要求 `!live` 永远不成立,
+   * 全局提示也就永远不弹。所以等待超过 `TURN_TIMEOUT_MS` 后不再算「还在等」,
+   * 交给超时逻辑收尾 → 占位消失、全局提示出现。
+   */
+  const waitingTooLong =
+    awaitingFirstReply && waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
+  const live =
+    !src.closed && !waitingTooLong && (awaitingFirstReply || typingAlive || idleFor < quietMs);
+  /*
+   * 用时读数(2026-11-10 合并口径):
    *
    * | 字段 | 锚点 | 用途 |
    * |---|---|---|
    * | `startAt`   | **用户发出问话的时刻** | 头部与段头的实时读数(1→2→3… 每秒加) |
    * | `seconds`   | 首条 → 末条 agent 消息 | 容器**真实处理耗时**,结束时定格,不跳变 |
    *
-   * 用户读秒从「我发出去了」算起才对得上体感;容器耗时从它真正开始回算才是准的。
-   * 进行中的读数由渲染层用每秒时钟现算(`elapsedSeconds()`)。
+   * 进行中的读数由渲染层用每秒时钟现算(`elapsedSeconds()`),
+   * 不再用「消息事件时间戳相减」——那样只会 5s → 18s → 30s 地跳。
    */
   const startAt = waitingSince > 0 ? waitingSince : src.startedAt;
 
@@ -419,13 +441,29 @@ function toBlockView(
   };
 }
 
-/** 由各块合成回合阶段 */
+/**
+ * 由各块合成回合阶段。
+ *
+ * 🔴 2026-11-10:「已完成」必须真的代表**有东西交付给用户**。
+ *
+ * 原判据是「只要不是全空就算 done」,于是容器只回了一串内部进度
+ * (`🐍 Running code…` + 一句日志)时,卡片照样标「已完成」,
+ * 用户打开一看什么也没有(实测截图)。
+ *
+ * 现在区分三档:
+ *   · 有正文 或 有文件 → `done`(真的交付了)
+ *   · 只有过程步骤、没有正文也没有文件 → `progressOnly`(干了活,但没交付)
+ *   · 什么都没有 → `idle`
+ */
 function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
   if (blocks.some((b) => b.live && (b.body.length > 0 || b.steps.length > 0))) return 'replying';
   if (blocks.some((b) => b.live)) return 'thinking';
   if (blocks.length === 0) return closed ? 'idle' : 'thinking';
-  // 全部结束:有内容就算完成,全空算未响应
-  return blocks.some((b) => !b.empty) ? 'done' : 'idle';
+
+  const delivered = blocks.some((b) => b.body.length > 0 || b.files.length > 0);
+  if (delivered) return 'done';
+  // 只剩过程步骤:做过事,但没有可交付的内容
+  return blocks.some((b) => b.steps.length > 0) ? 'progressOnly' : 'idle';
 }
 
 /**
@@ -466,18 +504,26 @@ export function elapsedSeconds(block: TurnBlockView, now: number): number {
  * |---|---|---|
  * | 已发出,还没有任何输出 | `live` 且无内容 | 8 秒内「正在思考…」;之后「已送达,等待回复…(Ns)」 |
  * | 开始输出 | `live` 且有内容 | 「正在输出…(Ns)」 |
- * | 结束且全空 | 非 live 且 empty | 「未响应」 |
+ * | 结束且全空 | 非 live 且 empty | **空**(不再显示「未响应」气泡) |
  *
  * 🔴 文案要**一直兜底到回复真的回来**,中途不能消失:
  *    容器回一轮要十几秒,界面空白会被理解成「发了没反应」。
  *
+ * 🔴 2026-11-10 用户裁决:**超时不再在会话里留「未响应」气泡**。
+ *
+ * 原实现回合结束后恒显示一个「⊙ 未响应」的小气泡,永远留在消息流里,
+ * 看起来像「每条都失败了」。改为:
+ *   · 会话内**完全移除**该占位(返回空串,段级判据同步不再渲染)
+ *   · 由页面层弹**全局提示**(可手动关闭、不自动消失),见 `useTurnTimeout()`
+ *
  * @param waitMs 距用户发出消息已经过了多久
  * @param hasOutput 是否已经开始输出内容
  * @param live 是否仍在进行中
  * @param empty 是否已结束且什么也没有
  */
 export function waitText(waitMs: number, hasOutput: boolean, live: boolean, empty: boolean): string {
-  if (!live) return empty ? '未响应' : '';
+  // 已结束:无论有没有内容,都不再留等待占位(超时提示交给全局 toast)
+  if (!live) return '';
   const sec = Math.max(0, Math.round(waitMs / 1000));
   if (hasOutput) return sec > 0 ? `正在输出…(${sec}s)` : '正在输出…';
   if (waitMs < WAIT_HINT_MS) return '正在思考…';
@@ -487,6 +533,25 @@ export function waitText(waitMs: number, hasOutput: boolean, live: boolean, empt
 /** 超过这个时长就明确告诉用户「发出了、在等」,不再只是「正在思考」 */
 export const WAIT_HINT_MS = 8_000;
 
+/**
+ * 判定「这一轮是否已经超时未响应」(2026-11-10)。
+ *
+ * 超时 = 用户已发出消息、但经过 `timeoutMs` 仍**没有任何 agent 输出**,且回合已结束。
+ * 页面据此弹全局提示(手动关闭)。
+ *
+ * @param waitMs  距用户发出消息的毫秒数
+ * @param hasOutput agent 是否已经有正文或步骤
+ * @param live    回合是否仍在进行
+ */
+export function isTurnTimedOut(
+  waitMs: number,
+  hasOutput: boolean,
+  live: boolean,
+  timeoutMs = TURN_TIMEOUT_MS,
+): boolean {
+  return !live && !hasOutput && waitMs >= timeoutMs;
+}
+
 /* ------------------------------------------------------------------ */
 /* 两种输入 → 统一的回合视图                                           */
 /* ------------------------------------------------------------------ */
@@ -536,7 +601,8 @@ export function turnsFromGroup(
       userFiles: t.userFiles ?? [],
       blocks,
       phase: phaseOf(blocks, t.closed),
-      seconds: roundSeconds(t.blocks),
+      // 与块级同口径:从用户发消息起算,live 时走当前时钟(时间戳取自原始块)
+      seconds: roundSeconds(t.blocks, now, live, t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0),
       live,
       closed: t.closed,
       stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
@@ -687,11 +753,30 @@ function mergeBodies(blocks: { body: TurnBodyBlock[] }[]): TurnMergedBody | null
 }
 
 /** 回合耗时:首条 agent 消息到末条(各块取并集,避免多块重复计算) */
-function roundSeconds(blocks: { startedAt: number; updatedAt: number }[]): number {
+/**
+ * 回合级耗时(群聊汇总用)。
+ *
+ * 🔴 必须与块级 `toBlockView` 同一口径(2026-11-10),否则同一张卡片上
+ *    段头写一个数、汇总写另一个数,用户会以为界面在乱跳。
+ *
+ * 口径:从**用户发出消息**起算。live 时取当前时钟(稳定递增),
+ *       结束后冻结在最晚的一条消息时间。多块之间取「最大结束 - 用户消息」,
+ *       也就是「这一轮最忙的那个成员干完为止」。
+ */
+function roundSeconds(
+  blocks: { startedAt: number; updatedAt: number }[],
+  now: number,
+  live: boolean,
+  waitingSince: number,
+): number {
   const starts = blocks.map((b) => b.startedAt).filter((n) => n > 0);
   const ends = blocks.map((b) => b.updatedAt).filter((n) => n > 0);
-  if (starts.length === 0 || ends.length === 0) return 0;
-  return Math.max(0, Math.round((Math.max(...ends) - Math.min(...starts)) / 1000));
+  // 锚点优先取用户消息,没有才回落到最早的一条 agent 消息
+  const anchor = waitingSince > 0 ? waitingSince : starts.length ? Math.min(...starts) : 0;
+  if (anchor <= 0) return 0;
+  const endAt = live ? now : ends.length ? Math.max(...ends) : 0;
+  if (endAt < anchor) return 0;
+  return Math.max(0, Math.round((endAt - anchor) / 1000));
 }
 
 /** 便捷入口:判断回合里是否有该文件(避免模板里重复过滤) */

+ 6 - 5
src/app/core/chat-session.service.ts

@@ -866,15 +866,16 @@ export class ChatSessionService {
    * 调用时机:会话里出现带 `cloudUrl` 的附件 / 有新成果上云时。
    * 幂等:同一链接不重复加;已有链接不会被覆盖掉。
    */
-  addDeliverableUrl(id: string, url: string | null | undefined): void {
-    const u = (url ?? '').trim();
-    if (!u) return;
+  addDeliverableUrl(id: string, ...urls: (string | null | undefined)[]): void {
+    const add = urls.map((x) => (x ?? '').trim()).filter((u) => u.length > 0);
+    if (add.length === 0) return;
     const cur = this.byId(id);
     if (!cur) return;
     const list = cur.deliverableUrls ?? [];
-    if (list.includes(u)) return;
+    const fresh = add.filter((u) => !list.includes(u));
+    if (fresh.length === 0) return;
     // 上限 50,与服务端 `_strArr` 的截断一致
-    const next = [...list, u].slice(0, 50);
+    const next = [...list, ...fresh].slice(0, 50);
     this.update(id, { deliverableUrls: next });
     /*
      * 🔴 立刻落库:成果归属是跨设备可见的数据,只留本地没有意义。

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

@@ -1,5 +1,5 @@
 import { TestBed } from '@angular/core/testing';
-import { ChatService } from './chat.service';
+import { ChatService, isAttachmentEcho } from './chat.service';
 import { FmodeApi } from './api/fmode-api';
 import { LiveService } from './live.service';
 import { MatrixClientService, MxMessage } from './matrix-client.service';
@@ -756,14 +756,22 @@ describe('ChatService', () => {
       expect(svc.pendingFiles()).toEqual([]);
     });
 
-    it('发送时上传附件(走 Matrix 原生事件),不在本地伪造文件消息', async () => {
+    it('发送时上传附件(走 Matrix 原生事件)+ 本地先插文件气泡', async () => {
       realMode();
       await svc.open('agent-node-lj-test');
+      // 2026-11-10:改为「先上云、再发 Matrix」,云盘是发送的前置条件
+      apiStub.uploadFile.and.returnValue(
+        Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
+      );
       svc.stageFiles([file()]);
       await svc.sendText('看看这个');
 
-      // 2026-11-10:附件气泡以回读的 m.image 为准,本地不再插 local-file-* 消息
-      expect(svc.messages().some((m) => m.id.startsWith('local-file-'))).toBeFalse();
+      /*
+       * 本地先插一条文件气泡(用户裁决 2026-11-11:立即可见)——
+       * 回读的 `m.image` 会再落一条,靠回合聚合的 `USER_MERGE_MS` 并成同一回合,
+       * 见 turn-aggregator 的「带附件的 me 消息并入上一回合」用例。
+       */
+      expect(svc.messages().some((m) => m.id.startsWith('local-file-'))).toBeTrue();
       // 走 Matrix 原生附件,不再依赖 OBS 预签名地址
       expect(mxStub.sendFile).toHaveBeenCalled();
       expect(svc.uploading()).toBeFalse();
@@ -797,9 +805,87 @@ describe('ChatService', () => {
       expect(svc.pendingFiles().length).withContext('失败后应保留待重试').toBe(1);
     });
 
+    /*
+     * 2026-11-10:上云顺序调整为「先 S3、再 Matrix」。
+     * 目的:不发出去「聊天记录里有、云盘里没有」的半成品文件。
+     */
+    it('🔴 云盘失败 → 不发 Matrix、附件保留待重试', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      apiStub.uploadFile.and.returnValue(Promise.reject(new Error('云盘不可用')));
+      mxStub.sendFile.calls.reset();
+
+      svc.stageFiles([file()]);
+      await svc.sendText('附上');
+
+      expect(mxStub.sendFile).not.toHaveBeenCalled();
+      expect(svc.error()).toContain('云盘不可用');
+      expect(svc.pendingFiles().length).withContext('未上云的文件不得被清掉').toBe(1);
+    });
+
+    it('🔴 顺序为先云盘后 Matrix(避免半成功)', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      const order: string[] = [];
+      apiStub.uploadFile.and.callFake(() => {
+        order.push('s3');
+        return Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' });
+      });
+      mxStub.sendFile.and.callFake(() => {
+        order.push('matrix');
+        return Promise.resolve();
+      });
+
+      svc.stageFiles([file()]);
+      await svc.sendText('附上');
+
+      expect(order).toEqual(['s3', 'matrix']);
+    });
+
+    /* 大小限制(2026-11-10):选文件时就拦,超限项不进暂存区 */
+    it('🔴 超过 20MB 的文件在 stageFiles 阶段被拒并提示', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      const big = new File([new Uint8Array(8)], 'big.zip');
+      Object.defineProperty(big, 'size', { value: 21 * 1024 * 1024 });
+
+      svc.stageFiles([big]);
+
+      expect(svc.pendingFiles().length).toBe(0);
+      expect(svc.error()).toContain('超过单个附件上限');
+    });
+
+    it('未超限的文件正常进入暂存区', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      const ok = new File([new Uint8Array(8)], 'ok.txt');
+      Object.defineProperty(ok, 'size', { value: 20 * 1024 * 1024 });
+
+      svc.stageFiles([ok]);
+
+      expect(svc.pendingFiles().length).toBe(1);
+    });
+
+    it('多选时只挡掉超限的,合格的照常暂存', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      const small = file();
+      const big = new File([new Uint8Array(8)], 'big.zip');
+      Object.defineProperty(big, 'size', { value: 30 * 1024 * 1024 });
+
+      svc.stageFiles([small, big]);
+
+      expect(svc.pendingFiles().length).toBe(1);
+      expect(svc.pendingFiles()[0].file.name).toBe('doc.txt');
+      expect(svc.error()).toContain('big.zip');
+    });
+
     it('只有附件没有文字也能发送', async () => {
       realMode();
       await svc.open('agent-node-lj-test');
+      apiStub.uploadFile.and.returnValue(
+        Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
+      );
       mxStub.sendFile.calls.reset();
       mxStub.sendText.calls.reset();
       svc.stageFiles([file()]);
@@ -1211,3 +1297,26 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
     });
   });
 });
+
+/**
+ * 附件事件的 body 是文件名(Matrix 协议),不能当正文再显示一遍。
+ * 2026-11-10 实测截图:发一个文件后界面出现「卡片 + 同名纯文字」两条。
+ */
+describe('isAttachmentEcho —— 附件 body 判定', () => {
+  it('body 等于文件名 → 视为回声,不显示正文', () => {
+    expect(isAttachmentEcho('report.md', 'report.md')).toBeTrue();
+  });
+
+  it('前后空白也认', () => {
+    expect(isAttachmentEcho('  report.md \n', 'report.md')).toBeTrue();
+  });
+
+  it('body 有额外说明 → 保留正文(容器常在附件同一条里写说明)', () => {
+    expect(isAttachmentEcho('这是本周的周报,请查收', 'report.md')).toBeFalse();
+  });
+
+  it('空 body 不当回声(交给上层按 undefined 处理)', () => {
+    expect(isAttachmentEcho('', 'report.md')).toBeFalse();
+    expect(isAttachmentEcho(undefined, 'report.md')).toBeFalse();
+  });
+});

+ 279 - 125
src/app/core/chat.service.ts

@@ -12,8 +12,8 @@ import {
   MatrixProvision,
 } from './api/models';
 import { DEMO_MESSAGES } from './api/demo-data';
-import { extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
-import { DeclaredFile, parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
+import { extractDeliveredLinks, extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
+import { isOversize, oversizeMessage } from './upload-limits';
 import { environment } from '../../environments/environment';
 import { LiveService } from './live.service';
 import { SessionService } from './session.service';
@@ -953,12 +953,10 @@ export class ChatService {
      */
     for (const m of all) {
       if (m.sender !== 'agent') continue;
-      /* 与实时路径同口径:优先声明段,否则正则兜底 + 文件判定 */
-      const declared = parseDeclaredFiles(m.text);
-      if (declared !== null) {
-        for (const d of declared) {
-          if (!isUserUploadUrl(d.url)) this.sessions.addDeliverableUrl(sid, d.url);
-        }
+      /* 与实时路径同口径:优先容器交付段落,否则正则兜底 + 文件判定 */
+      const delivered = extractDeliveredLinks(m.text);
+      if (delivered.length > 0) {
+        this.sessions.addDeliverableUrl(sid, ...delivered);
       } else {
         for (const u of extractS3Links(m.text)) {
           if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
@@ -1394,7 +1392,13 @@ export class ChatService {
          */
         if (this._pendingFiles().length > 0) {
           const { fail } = await this.uploadPending(conversationId);
-          if (fail > 0) this._error.set(`${fail} 个附件上传失败,可重试`);
+          /*
+           * 具体原因由 uploadPending 逐条写入(哪个文件、为什么失败);
+           * 这里只在**没有具体原因**时补一句汇总,避免覆盖掉有用信息。
+           */
+          if (fail > 0 && !this._error()) {
+            this._error.set(`${fail} 个附件上传失败,可重试`);
+          }
         }
 
         if (!body) {
@@ -1486,6 +1490,43 @@ export class ChatService {
 
   // ---------- 附件:先暂存,发送时一起提交 ----------
 
+  /**
+   * 把文件存进云盘(公开入口,供群聊等其它发送链路复用)。
+   *
+   * 单聊的 `uploadPending` 与群聊派活都要「文件进云盘」,但群聊
+   * 只需存**一次**(单聊里每个房间的 Matrix 副本是另一回事)。
+   * 这里只负责云盘那一半,失败抛错由调用方决定文案。
+   */
+  async uploadToCloud(file: File): Promise<{ key: string; publicUrl: string }> {
+    if (isOversize(file)) throw new Error(oversizeMessage(file));
+    return this.api.uploadFile(file);
+  }
+
+  /**
+   * 把已上传云盘的文件登记进文件台账(2026-11-10)。
+   *
+   * 单聊与群聊共用:`threadRoot` 传会话 id —— 单聊是对话 id,群聊是群会话 id,
+   * 这样右侧栏按会话查时互不串台。
+   */
+  async registerSessionFile(
+    threadRoot: string,
+    file: File,
+    up: { key: string; publicUrl: string },
+    agentId?: string,
+  ): Promise<void> {
+    await this.api.registerFile({
+      threadRoot,
+      agentId,
+      roomId: this.roomId ?? undefined,
+      name: file.name,
+      url: up.publicUrl,
+      key: up.key,
+      mimeType: file.type || 'application/octet-stream',
+      size: file.size,
+      uploadedBy: 'user',
+    });
+  }
+
   /**
    * 选择附件(**只暂存,不上传、不发送**)。
    *
@@ -1493,7 +1534,18 @@ export class ChatService {
    * 早期实现是「选完立刻上传并单发一条」—— 用户只想要图文一起发,那个行为是错的。
    */
   stageFiles(files: FileList | File[]): void {
-    const list = Array.from(files);
+    const all = Array.from(files);
+    if (all.length === 0) return;
+    /*
+     * 大小拦截的第一层(2026-11-10):选文件时就挡掉超限项。
+     * 第二层在 uploadPending 里兜底(防暂存期替换/绕过)。
+     * 超限项**不进暂存区**,用户立刻看到原因 —— 比发出去再失败好。
+     */
+    const list = all.filter((f) => !isOversize(f));
+    const rejected = all.filter((f) => isOversize(f));
+    if (rejected.length > 0) {
+      this._error.set(rejected.map(oversizeMessage).join(';'));
+    }
     if (list.length === 0) return;
     this._pendingFiles.update((cur) => [
       ...cur,
@@ -1542,6 +1594,16 @@ export class ChatService {
     try {
       for (let i = 0; i < pending.length; i++) {
         const { file } = pending[i];
+        /*
+         * 大小兜底(2026-11-10):stageFiles 已拦过一层,这里防「暂存后文件被替换」
+         * 或调用方绕过 stageFiles。超限项按失败处理,留在待发区让用户移除。
+         */
+        if (isOversize(file)) {
+          this._error.set(oversizeMessage(file));
+          fail++;
+          failedIds.add(pending[i].id);
+          continue;
+        }
         try {
           /*
            * 🔴 直接走 **Matrix 原生附件**,不再经过 OBS 预签名 + 自定义 JSON。
@@ -1553,65 +1615,120 @@ export class ChatService {
            * 现在:文件内容直接上传 Matrix 媒体库 → 发原生事件。
            * `publicUrl` 仅用于界面本地展示,OBS 那条链路不再参与发送。
            */
+          /*
+           * 🔴 顺序:**先 S3、再 Matrix**(2026-11-10 调整)。
+           *
+           * 原先是 Matrix 先发、S3 后传,结果是「消息已经发出去了、云盘却没存上」,
+           * 用户看到的是一个只存在于聊天记录里、换设备就打不开的文件。
+           *
+           * 现在反过来:云盘存不上就**不发 Matrix**,失败项留在待发区重试。
+           * 这样「发出去的文件 = 一定能长期访问的文件」,不会出现半成功状态。
+           *
+           * 为什么要两份:
+           *   Matrix 那份给容器读(容器只认 `m.file` + `mxc://`,实测直链它收不到);
+           *   S3   那份给 App 长期查看与成果归档(Matrix 媒体会被平台清理)。
+           */
           const mimeType = file.type || 'application/octet-stream';
           if (!this.roomId) throw new Error('会话未就绪');
           const sid = this._sessionId();
+
+          /* ① 云盘:失败即中止本文件(不发送) */
+          const up = await this.api.uploadFile(file);
+          const cloudUrl = up.publicUrl;
           /*
-           * 🔴 「两条都存」(2026-11-09 用户裁决)。
-           *
-           * 用户要求:文件处理完必须上传云盘,之后 App 里查看**都走云盘**。
+           * `key` 是云盘里的稳定对象标识,`publicUrl` 只是它的投影。
+           * 以前只留了 url、把 key 丢了 —— 将来要按用户/会话检索文件时,
+           * key 才是能对上号的字段,所以一并记进成果。
+           */
+          /*
+           * ② 登记到文件台账(2026-11-10)。
            *
-           * ⚠️ 但**不能**像早期那样「只走 OBS」—— 容器只认 Matrix 的
-           *    `m.file` / `m.image` 原生事件,换成 S3 直链它会回「没收到附件」。
+           * 用户要求:文件与成果入库,右侧栏**直接按会话从数据库加载**,
+           * 不再依赖「先加载聊天历史才看得到」。
            *
-           * 所以:Matrix 照常发(供容器读取与消息流回溯),
-           *       S3 **额外**存一份(供 App 长期查看与成果归档)。
-           * S3 失败**不阻断发送** —— 降级为只有 Matrix,并记一条告警。
+           * 登记失败不阻断发送(文件已经在云盘上了,只是台账缺一条),
+           * 但要如实提示 —— 不能让用户以为文件没上去。
            */
+          if (sid) {
+            try {
+              await this.api.registerFile({
+                threadRoot: sid,
+                agentId: this._agentId() ?? undefined,
+                roomId: this.roomId,
+                name: file.name,
+                url: cloudUrl,
+                key: up.key,
+                mimeType,
+                size: file.size,
+                uploadedBy: 'user',
+              });
+            } catch (e) {
+              this._error.set(
+                `「${file.name}」已存入云盘,但未记入文件列表:${e instanceof Error ? e.message : '登记失败'}`,
+              );
+            }
+          }
+
+          /* ② Matrix:供容器读取 */
           await this.mx.sendFile(
             this.roomId,
             file,
             { name: file.name, size: file.size, mimeType },
             sid ?? undefined,
           );
-          let cloudUrl: string | null = null;
-          try {
-            const up = await this.api.uploadFile(file);
-            cloudUrl = up.publicUrl;
-            /*
-             * 🔴 用户上传的文件**不记进会话成果列表**(2026-11-10 修复)。
-             *
-             * 原实现这里直接 `addDeliverableUrl(sid, cloudUrl)`,
-             * 结果自己发的图同时出现在右栏「文件」和「成果」两处
-             * (用户实测截图)。「成果」= 容器产出的东西,
-             * 用户文件只属于「文件」页签(由消息流 sender='me' 渲染)。
-             *
-             * 云盘地址本身仍有用:回读的 `m.image` 只有 `mxc://`,
-             * 这里记进 `_cloudUrls`,回读时补回 `FileCard.cloudUrl`(2026-11-10)。
-             */
-            if (cloudUrl) this._cloudUrls.set(this.cloudKeyOf(file.name, file.size), cloudUrl);
-          } catch (e) {
-            /* 如实记录:这条文件只在 Matrix,没进云盘 */
-            this._error.set(
-              `「${file.name}」已发送,但未存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
-            );
-          }
+
+          /*
+           * 上传进度按文件粒度推进:云盘这一步已经完成,Matrix 发送紧随其后,
+           * 期间用户看到的是输入框进度,不是空白。
+           */
           this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
           /*
-           * 🔴 不再本地插入「文件消息」(2026-11-10 修复)。
+           * 云盘地址记进 `_cloudUrls`:回读的 `m.image` 只有 `mxc://`,
+           * 没这张表就补不回 `FileCard.cloudUrl`(2026-11-10 合并修复)。
            *
-           * 原实现在这里造一条 `local-file-*` 的 me 消息,同时 Matrix 的
-           * `m.image` echo 回读后又落一条 —— 配合回合聚合「每条 me 开新回合」,
-           * 一次发送配图就出现两个用户气泡(用户实测截图)。
+           * 🔴 文件**不进**会话成果列表(`deliverableUrls`):用户自己发的
+           *    东西只属于右栏「文件」页签,「成果」= 容器产出的东西。
+           */
+          this._cloudUrls.set(this.cloudKeyOf(file.name, file.size), cloudUrl);
+
+          // 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
+          const displayUrl = URL.createObjectURL(file);
+          const card: FileCard = {
+            kind: 'file',
+            name: file.name,
+            size: file.size,
+            mimeType,
+            url: displayUrl,
+            /* 云盘地址:S3 先行之后,能走到这里的文件一定两边都有 */
+            cloudUrl,
+          };
+          /*
+           * 本地先插一条文件气泡,发出去立刻可见(用户裁决:本地先插)。
            *
-           * 现在:即时反馈交给输入框的上传进度(`uploading()` / `uploadPct()`),
-           * 附件气泡一律以回读的 `m.image` 为准,与文字合并成同一回合。
-           * 回读一般在秒级内到达,期间用户看到的是上传进度,不是空白。
+           * ⚠️ 回读的 `m.image` 还会再落一条 —— 依赖回合聚合的
+           *    `USER_MERGE_MS` 把两条并成同一回合,若出现「两个用户气泡」,
+           *    先查这里而不是直接删本地插入。
            */
+          this._messages.update((m) => [
+            ...m,
+            {
+              id: `local-file-${Date.now()}-${file.name}`,
+              conversationId,
+              sender: 'me',
+              kind: 'text',
+              file: card,
+              ts: new Date().toISOString(),
+              state: 'sent',
+            },
+          ]);
           ok++;
-        } catch {
+        } catch (e) {
           fail++;
           failedIds.add(pending[i].id);
+          /* 如实告知失败原因(S3 先行后,这里通常是「云盘没存上」⇒ 未发送) */
+          this._error.set(
+            `「${file.name}」发送失败:${e instanceof Error ? e.message : '上传失败'}`,
+          );
         }
       }
     } finally {
@@ -1882,27 +1999,40 @@ export class ChatService {
   ): boolean {
     if (!m.threadId) {
       /*
-       * 房间级消息(无 thread)的归属(2026-11-09 用户裁决调整)。
+       * 房间级消息(无 thread)的归属。
+       *
+       * ## 历史背景
+       *
+       * 2026-11-09 用户裁决:「房间级的这个消息,可以丢弃掉,没有问题」。
+       * 当时的原因:外部脚本往房间发的调试消息没有 thread,
+       * 被新建会话全部认领,表现为「新建会话出现一堆历史」。
        *
-       * 用户原话:「房间级的这个消息,可以丢弃掉,没有问题」。
+       * ## 2026-11-10 修正:容器明确不挂 thread
        *
-       * 背景:同一个 DM 房间里,App 的正常会话**都带 thread**;
-       * 只有两种情况会产生无 thread 的消息:
-       *   ① 群聊派活(`sendToAgent` 发房间级)—— 有 `dispatch` 标记可识别
-       *   ② **外部工具 / 脚本**直接往房间发的消息 —— 没有任何归属信息
+       * 向 `agent-node-lj-test` 核实,容器原话:
+       *   「不会。我只回一条普通消息,不主动挂 m.relates_to 的 thread。」
        *
-       * ② 是实测踩到的坑:用脚本调试容器发出的消息没有 thread,
-       * 结果被新建会话全部认领,表现为「新建会话出现一堆历史」。
+       * 也就是说**容器在 DM 里的正常回复本来就没有 thread**。
+       * 一刀切丢弃的结果是:容器回复可能整段消失(用户实测「完全没反应」)。
        *
-       * 现在的规则:**一律不认领**。唯一例外是单聊自己发的房间级附件 ——
-       * 那种消息带 `sessionId` 标记,归属是明确的。
+       * ## 现在的规则:**有条件的认领**
        *
-       * 代价:容器历史上无 thread 的旧回复不再显示。这是有意的取舍 ——
-       * 宁可少显示,也不要把别处的消息错认成这个会话的。
+       * 同时满足以下全部条件才认领(缺一不可,确保不误收别处消息):
+       *   ① `live === true` —— 实时到达,不是历史回填
+       *      (历史回填时无法判断归属,仍按老规则丢弃)
+       *   ② 本会话**正有待回复的消息** —— 已发出 agent 尚未回复
+       *   ③ 消息时间**晚于**那条待回复的用户消息
+       *
+       * 这样:
+       *   · 用户发消息 → 容器回的消息一定会被认领(这正是「没反应」的修复)
+       *   · 会话闲置时,别人往房间发的消息不会被错收
+       *   · 刷新/回填时仍严格,避免「新建会话冒出一堆历史」
        */
       if (m.dispatch) return false;
       if (m.sessionId) return m.sessionId === sessionId;
-      return false;
+      if (!live) return false;
+      if (m.sender === (this.provision?.userMxId ?? '')) return false;
+      return this.isAwaitingReplyAfter(sessionId, m.ts || 0);
     }
     if (m.threadId === sessionId) return true;
     /*
@@ -1939,6 +2069,37 @@ export class ChatService {
     return roots.has(m.threadId);
   }
 
+  /**
+   * 本会话是否「已发出消息、容器还没回」(2026-11-10,B)。
+   *
+   * 用于判断一条**无 thread** 的容器消息该不该认领:
+   * 只有在「确实在等回复」时才收,避免会话闲置时把房间里别处的消息错收进来。
+   *
+   * @param sessionId 当前会话
+   * @param at        候选消息的时间戳
+   * @returns true = 存在一条**早于 `at`** 的用户消息,且其后还没有容器输出
+   */
+  private isAwaitingReplyAfter(sessionId: string, at: number): boolean {
+    if (!sessionId || !at) return false;
+    const list = this._messages();
+    // 最后一条来自用户的消息
+    let lastUserAt = 0;
+    for (let i = list.length - 1; i >= 0; i--) {
+      const msg = list[i];
+      if (msg.sender !== 'me') continue;
+      lastUserAt = Date.parse(msg.ts) || 0;
+      break;
+    }
+    if (!lastUserAt || at <= lastUserAt) return false;
+    // 那条用户消息之后,已经有过 agent 输出吗?有则本轮已回复,不再收无 thread 消息
+    const replied = list.some((msg) => {
+      if (msg.sender !== 'agent') return false;
+      const ts = Date.parse(msg.ts) || 0;
+      return ts > lastUserAt;
+    });
+    return !replied;
+  }
+
   /**
    * 算出某会话「拥有」的容器线程根(2026-11-08)。
    *
@@ -2087,34 +2248,48 @@ export class ChatService {
        * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
        */
       /*
-       * 文件来源(2026-11-10 起按优先级):
+       * 文件来源(2026-11-10 取证后重排优先级):
        *
        *   ① Matrix 原生附件(`m.file` / `m.media`) —— 最可靠
-       *   ② **容器声明段** `<!--fmode:files [...] -->` —— 结构化,不需要猜
+       *   ② **容器交付段落** `**已上云(可直接下载)**` + `- url` 列表
        *   ③ 正文里的云盘链接(正则提取) —— 老格式的兜底
        *
-       * 🔴 ② 是新增的:原先只有 ①③,而 ③ 靠正则猜 URL,
-       *    已经踩过两次坑(容器举例的 `https://s3.fmode.cn/...` 被当成真文件、
-       *    用户提问里的链接被收成成果)。声明段是容器**显式说**有哪些文件。
+       * 🔴 原先的 ② 是 `<!--fmode:files [...]-->` 结构化声明段。
+       *    2026-11-10 向容器取证确认:**容器从未实现该协议**,输出侧不写任何
+       *    结构化注释,实际写的是 ② 这种「小标题 + 链接列表」。那条分支
+       *    永远命中不了,已删除(容器原话见 docs/03-interface/容器取证-*.md Q12)。
        *
-       * 声明段存在时**不再**走 ③ —— 容器已经明确表态,不需要再猜。
-       * 没有声明段时才回退到 ③(老容器仍可用)。
+       * 交付段落存在时优先用它,因为那里的链接是容器**明确声明**的成果;
+       * 正文其它位置的链接可能是举例或引用,不该当文件。
        */
-      const declared = file ? null : parseDeclaredFiles(m.body);
       let linkCard: FileCard | undefined;
+      /**
+       * 正文文本(2026-11-10 重构)。
+       *
+       * 三种情况:
+       *   ① 带 Matrix 附件 → `body` 只是文件名,不当正文
+       *   ② 有交付段落 → 完整保留(用户要能直接点链接)
+       *   ③ 只有裸链接 → 剥掉链接交给卡片,避免卡片与正文重复
+       */
       let bodyText = m.body;
 
-      if (declared !== null) {
-        // 有声明段:正文去掉那段 JSON(用户不该看到),文件从声明里取
-        bodyText = stripDeclaredFiles(m.body);
-        const first = declared.find((d) => d.url);
-        if (first) linkCard = this.cardFromDeclared(first);
-      } else if (!file) {
-        // 没有声明段:回退到「从正文正则提取」(老格式)
-        const cloudLinks = extractS3Links(m.body);
-        linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
-        bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
+      if (!file) {
+        const delivered = extractDeliveredLinks(m.body);
+        if (delivered.length > 0) {
+          linkCard = this.cardOfCloudLink(delivered[0]);
+          // 交付段落本身保留(用户可能想直接点),只去掉重复的单条链接由卡片承载
+          bodyText = m.body;
+        } else {
+          // 没有交付段落:回退到「从正文正则提取」(老格式)
+          const cloudLinks = extractS3Links(m.body);
+          linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
+          bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
+        }
+      } else if (isAttachmentEcho(m.body, file.name)) {
+        /* Matrix 原生附件的 body 是文件名(协议要求),不是正文 —— 别当纯文字再显示一遍 */
+        bodyText = '';
       }
+
       return {
         id: m.eventId || `${m.ts}-${m.sender}`,
         conversationId: cid,
@@ -2152,44 +2327,6 @@ export class ChatService {
     });
   }
 
-  /**
-   * 把声明段里的一项包成附件卡片(2026-11-10)。
-   *
-   * 与 `cardOfCloudLink` 的区别:这里的**文件名和大小是容器给的**,不用猜。
-   */
-  private cardFromDeclared(d: DeclaredFile): FileCard {
-    const ext = (d.name.split('.').pop() ?? '').toLowerCase();
-    const mimeByExt: Record<string, string> = {
-      md: 'text/markdown',
-      markdown: 'text/markdown',
-      html: 'text/html',
-      htm: 'text/html',
-      pdf: 'application/pdf',
-      txt: 'text/plain',
-      csv: 'text/csv',
-      json: 'application/json',
-      png: 'image/png',
-      jpg: 'image/jpeg',
-      jpeg: 'image/jpeg',
-      gif: 'image/gif',
-      webp: 'image/webp',
-      svg: 'image/svg+xml',
-      xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-      docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-      pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
-      zip: 'application/zip',
-      mp4: 'video/mp4',
-    };
-    return {
-      kind: 'file',
-      name: d.name,
-      size: d.size,
-      mimeType: mimeByExt[ext] ?? 'application/octet-stream',
-      url: d.url,
-      cloudUrl: d.url,
-    };
-  }
-
   /**
    * 把一条云盘链接包成附件卡片。
    *
@@ -2343,20 +2480,20 @@ export class ChatService {
       }
     }
     /*
-     * 成果链接记入会话(2026-11-10 起按优先级):
+     * 成果链接记入会话(2026-11-10 取证后重排):
      *
-     *   ① 容器声明段 `<!--fmode:files-->` —— **显式声明,最可靠**
+     *   ① 容器交付段落 `**已上云(可直接下载)**` + `- url` —— 容器**实际会写**的格式
      *   ② 正文里的云盘链接 —— 老格式兜底,且必须通过文件判定
      *
-     * ⚠️ 之前只有 ②,必须同时满足:sender === 'agent' + 不是省略号占位
+     * 🔴 原先的 ① 是 `<!--fmode:files-->` 声明段,取证确认容器从未实现,已删除。
+     *
+     * ⚠️ ② 必须同时满足:sender === 'agent' + 不是省略号占位
      *    + 有合法扩展名。少一条就会把用户提问里的示例链接收成成果(已实测踩坑)。
      */
     if (mapped.sender === 'agent') {
-      const declared = parseDeclaredFiles(m.body);
-      if (declared !== null) {
-        for (const d of declared) {
-          if (!isUserUploadUrl(d.url)) this.sessions.addDeliverableUrl(sid, d.url);
-        }
+      const delivered = extractDeliveredLinks(m.body);
+      if (delivered.length > 0) {
+        this.sessions.addDeliverableUrl(sid, ...delivered);
       } else {
         for (const u of extractS3Links(mapped.text)) {
           if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
@@ -2502,3 +2639,20 @@ export class ChatService {
 function msgOf(e: unknown): string {
   return e instanceof Error && e.message ? e.message : '未知错误';
 }
+
+/**
+ * 判断附件事件的 `body` 只是「文件名的回声」。
+ *
+ * Matrix 协议规定 `m.file` / `m.image` 的 `body` 字段**就是文件名**,
+ * 渲染时若原样当正文输出,界面会出现「文件卡片 + 同名纯文字」两条(实测截图确认)。
+ *
+ * @param body     事件 body
+ * @param fileName 附件名
+ * @returns true 表示这段 body 没有信息量,不应作为正文显示
+ */
+export function isAttachmentEcho(body: string | undefined, fileName: string): boolean {
+  if (!body) return false;
+  const b = body.trim();
+  if (!b) return false;
+  return b === fileName.trim();
+}

+ 19 - 0
src/app/core/data-tools.ts

@@ -13,6 +13,7 @@
  * __fmodeData.clearDemo()   // 只删模拟数据($demo_ 前缀),保留真实会话
  * __fmodeData.clearSessions() // 清空会话索引(下次进入会重新从 Matrix 扫描)
  * __fmodeData.dump()        // 打印当前各键的数量,排查用
+ * __fmodeData.previewAlert() // 预览全局异常提示(调样式用,2026-11-10)
  * ```
  *
  * 🔴 只清**本地索引**,不碰 Matrix 服务端消息。
@@ -21,6 +22,8 @@ interface DataApi {
   clearDemo: () => void;
   clearSessions: () => void;
   dump: () => Record<string, unknown>;
+  /** 预览全局异常提示(开发期调样式用) */
+  previewAlert: () => void;
 }
 
 export function installDataApi(): void {
@@ -81,6 +84,22 @@ export function installDataApi(): void {
       console.table(out);
       return out;
     },
+
+    /*
+     * 预览全局异常提示(2026-11-10)。
+     *
+     * 调样式时需要立即看到效果,而真实超时要等 3 分钟。
+     * 只做前端展示,不改任何数据。
+     */
+    previewAlert() {
+      const svc = (window as unknown as { ng?: { get?: (t: unknown) => unknown } }).ng?.get?.(
+        // 惰性取 AlertService:避免 data-tools 与核心服务产生静态循环依赖
+        (globalThis as unknown as { __alertToken?: unknown }).__alertToken,
+      );
+      if (svc) return;
+      // 无 DI 访问通道时的兜底:直接派发自定义事件,由 App 根组件监听
+      window.dispatchEvent(new CustomEvent('fmode:preview-alert'));
+    },
   };
 
   (window as unknown as { __fmodeData?: DataApi }).__fmodeData = api;

+ 0 - 72
src/app/core/declared-files.spec.ts

@@ -1,72 +0,0 @@
-import { parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
-
-/**
- * 单测:容器交付协议(声明段解析)
- *
- * 背景:原先靠正则从正文里找 URL,已踩过两次坑
- * (举例链接被当真、用户提问里的链接被收成成果)。本协议改为容器显式声明。
- */
-describe('parseDeclaredFiles', () => {
-  const wrap = (json: string): string =>
-    `本周计划已生成。\n\n<!--fmode:files\n${json}\n-->\n`;
-
-  it('解析出文件清单', () => {
-    const out = parseDeclaredFiles(
-      wrap('[{"name":"a.md","url":"https://s3.fmode.cn/x/a.md","size":1587}]'),
-    );
-    expect(out).toEqual([{ name: 'a.md', url: 'https://s3.fmode.cn/x/a.md', size: 1587 }]);
-  });
-
-  it('多个文件按顺序返回', () => {
-    const out = parseDeclaredFiles(
-      wrap(
-        '[{"name":"a.md","url":"https://s3.fmode.cn/a.md"},{"name":"b.png","url":"https://s3.fmode.cn/b.png"}]',
-      ),
-    );
-    expect(out?.map((x) => x.name)).toEqual(['a.md', 'b.png']);
-  });
-
-  it('🔴 没有声明段 → 返回 null(调用方回退到正则)', () => {
-    expect(parseDeclaredFiles('普通回复,没有标记')).toBeNull();
-    expect(parseDeclaredFiles(undefined)).toBeNull();
-  });
-
-  it('🔴 有声明段但为空数组 → 返回 [](容器明确表示没有文件)', () => {
-    expect(parseDeclaredFiles(wrap('[]'))).toEqual([]);
-  });
-
-  it('🔴 JSON 写坏 → 返回 [],不回退去猜正文', () => {
-    expect(parseDeclaredFiles(wrap('{不是合法 JSON'))).toEqual([]);
-  });
-
-  it('缺少 name 时从 URL 推断', () => {
-    const out = parseDeclaredFiles(wrap('[{"url":"https://s3.fmode.cn/x/report.md"}]'));
-    expect(out?.[0].name).toBe('report.md');
-  });
-
-  it('缺少 size 时记为 -1(未知)', () => {
-    const out = parseDeclaredFiles(wrap('[{"name":"a.md","url":"https://s3.fmode.cn/a.md"}]'));
-    expect(out?.[0].size).toBe(-1);
-  });
-
-  it('过滤掉非法项(没有 url / url 不是 http)', () => {
-    const out = parseDeclaredFiles(
-      wrap(
-        '[{"name":"a.md"},{"name":"b","url":"ftp://x"},{"name":"c.md","url":"https://s3.fmode.cn/c.md"}]',
-      ),
-    );
-    expect(out?.length).toBe(1);
-    expect(out?.[0].name).toBe('c.md');
-  });
-});
-
-describe('stripDeclaredFiles', () => {
-  it('🔴 把声明段从正文里移除(用户不该看到 JSON)', () => {
-    const src = '本周计划已生成。\n\n<!--fmode:files\n[{"name":"a.md","url":"https://x/a.md"}]\n-->';
-    expect(stripDeclaredFiles(src)).toBe('本周计划已生成。');
-  });
-
-  it('没有声明段时原样返回(去掉首尾空白)', () => {
-    expect(stripDeclaredFiles('  普通回复  ')).toBe('普通回复');
-  });
-});

+ 0 - 100
src/app/core/declared-files.ts

@@ -1,100 +0,0 @@
-/**
- * 容器交付协议:结构化文件清单
- *
- * ## 为什么需要
- *
- * 原先靠**从正文正则找 URL** 来推断容器产出了哪些文件。这条路上已经踩过两次:
- *   ① 容器举例写 `https://s3.fmode.cn/...` → 被当成真实文件建卡
- *   ② 用户提问里写同样的 URL → 也被收进「成果」
- *
- * 根子是「靠猜」。改成**容器显式声明**:正文照常给人读,文件清单用标记段给机器解析。
- *
- * ## 协议
- *
- * 容器在回复末尾追加一段被 HTML 注释包裹的 JSON:
- *
- * ```
- * 本周工作计划已生成,含 5 条任务与时间分布。
- *
- * <!--fmode:files
- * [
- *   {"name": "weekly-plan-20261109.md", "url": "https://s3.fmode.cn/xxx/a.md", "size": 1587},
- *   {"name": "report.png", "url": "https://s3.fmode.cn/xxx/b.png"}
- * ]
- * -->
- * ```
- *
- * 用 HTML 注释包裹的好处:
- *   · 即使解析失败,Markdown 渲染器也不会把 JSON 显示成正文(注释不可见)
- *   · 人读回复时完全无感
- *
- * ## 兼容
- *
- * 没有标记段时返回 `null`,调用方回退到「从正文提取」(老格式仍可用)。
- */
-
-/** 标记段:`<!--fmode:files ... -->`,内容为 JSON 数组 */
-const FILES_BLOCK = /<!--\s*fmode:files\s*([\s\S]*?)-->/i;
-
-/** 清单里的一项 */
-export interface DeclaredFile {
-  name: string;
-  url: string;
-  /** 字节数;缺省 / 非法时为 -1(表示未知) */
-  size: number;
-}
-
-/**
- * 解析容器声明段里的文件清单。
- *
- * @param text 容器回复原文
- * @returns
- *   · `null` —— 没有声明段(老格式,调用方回退到正则提取)
- *   · `[]`   —— 有声明段但内容为空(容器明确表示「本次没有文件」)
- *   · 数组   —— 解析出的文件清单(已过滤非法项)
- */
-export function parseDeclaredFiles(text: string | undefined | null): DeclaredFile[] | null {
-  if (!text) return null;
-  const m = FILES_BLOCK.exec(text);
-  if (!m) return null;
-
-  let raw: unknown;
-  try {
-    raw = JSON.parse(m[1].trim());
-  } catch {
-    /*
-     * JSON 写坏了:返回空数组而不是 null ——
-     * 容器「声明了但写错」时,宁可不显示也不要用正文正则去猜,
-     * 否则又会把举例链接当成文件。
-     */
-    return [];
-  }
-  if (!Array.isArray(raw)) return [];
-
-  const out: DeclaredFile[] = [];
-  for (const it of raw) {
-    if (!it || typeof it !== 'object') continue;
-    const o = it as Record<string, unknown>;
-    const url = typeof o['url'] === 'string' ? o['url'].trim() : '';
-    if (!url || !/^https?:\/\//i.test(url)) continue;
-    const name =
-      typeof o['name'] === 'string' && o['name'].trim()
-        ? o['name'].trim()
-        : (decodeURIComponent(url.split('?')[0].split('/').pop() ?? '文件') || '文件');
-    const size = typeof o['size'] === 'number' && Number.isFinite(o['size']) && o['size'] >= 0
-      ? o['size']
-      : -1;
-    out.push({ name, url, size });
-  }
-  return out;
-}
-
-/**
- * 把声明段从正文里**移除**(用户不该看到那段 JSON)。
- *
- * 即使解析失败也移除 —— 它是给机器看的,不是给用户看的。
- */
-export function stripDeclaredFiles(text: string | undefined | null): string {
-  if (!text) return '';
-  return text.replace(FILES_BLOCK, '').trim();
-}

+ 1 - 0
src/app/core/deliverables.service.spec.ts

@@ -254,3 +254,4 @@ describe('DeliverablesService', () => {
     });
   });
 });
+

+ 87 - 1
src/app/core/deliverables.service.ts

@@ -7,7 +7,9 @@ import {
   DeliverableGroup,
   LoadState,
   Project,
+  SessionFile,
 } from './api/models';
+import { AlertService } from './alert.service';
 import { LiveService } from './live.service';
 import { isUserUploadUrl } from './s3-link';
 
@@ -23,6 +25,8 @@ export class DeliverablesService {
   private readonly api = inject(FmodeApi);
   /** 成果附件搬运到云盘(2026-11-07:下沉到 load 末尾,全端生效) */
   private readonly artifactSync = inject(ArtifactSyncService);
+  /** 上云失败要弹全局提示(2026-11-10) */
+  private readonly alerts = inject(AlertService);
 
   private readonly _items = signal<Deliverable[]>([]);
   private readonly _groups = signal<DeliverableGroup[]>([]);
@@ -130,7 +134,22 @@ export class DeliverablesService {
        * 下沉到加载完成的末尾:所有调用方(移动端、群聊、成果页)自动生效。
        * `syncPending` 内部幂等,桌面那处 effect 可以保留。
        */
-      void this.artifactSync.syncPending(items);
+      void this.artifactSync.syncPending(items).then((queued) => {
+        /*
+         * 上云失败要让人知道(2026-11-10)。
+         *
+         * 原先这里 `void` 掉结果:失败项进 `errors` signal 但没人读,
+         * 用户只看到成果表里一条「点不开的文件名」,完全不知道发生了什么。
+         * 现在有失败就弹全局提示(需手动关闭),并附上第一个失败原因。
+         */
+        const failed = [...this.artifactSync.errors().values()];
+        if (failed.length > 0) {
+          this.alerts.show(`${failed.length} 个成果未能上云`, failed[0]);
+        } else if (queued > 0) {
+          // 成功入队不打扰用户,只留痕便于排查
+          console.info(`[成果上云] 已入队 ${queued} 个附件`);
+        }
+      });
     } catch (e) {
       // AC-08.4:接口失败必须显示错误态,禁止伪装成「暂无成果」
       this._state.set('error');
@@ -163,6 +182,23 @@ export class DeliverablesService {
    *
    * @param urls 会话记录上的 `deliverableUrls`
    */
+  /**
+   * 按会话从**数据库**拉文件(2026-11-10,右侧栏主路径)。
+   *
+   * 用户裁决:每次直接按会话从数据库加载,不再依赖
+   * 「先加载聊天历史」或 `FmodeChatThread.deliverableUrls`。
+   *
+   * 返回的 `SessionFile` 直接映射成 `Deliverable` 视图,复用现有渲染层
+   * (右栏组件本来就吃 `Deliverable[]`,不用改模板)。
+   *
+   * 历史数据没有 `threadRoot`,查不到属预期 —— 用户明确「只保证新数据」。
+   */
+  async loadForSession(threadRoot: string): Promise<Deliverable[]> {
+    if (!threadRoot) return [];
+    const files = await this.api.fetchFilesBySession(threadRoot);
+    return files.map(toDeliverable);
+  }
+
   bySessionUrls(urls: readonly string[] | undefined): Deliverable[] {
     if (!urls || urls.length === 0) return [];
     const out: Deliverable[] = [];
@@ -227,6 +263,28 @@ export class DeliverablesService {
     return this._items().find((d) => d.id === id);
   }
 
+  /**
+   * 合并两组成果并去重(2026-11-10)。
+   *
+   * 去重键用**附件 URL** 而不是 `id`:
+   * 同一条成果可能一边来自 `deliverableUrls` 匹配(有完整 id),
+   * 一边来自 `fromAgentFiles`(`id = msg:<url>`),id 对不上但其实是同一个文件。
+   * 优先保留前者(信息更全,有标题/版本)。
+   */
+  merge(...groups: readonly Deliverable[][]): Deliverable[] {
+    const out: Deliverable[] = [];
+    const seen = new Set<string>();
+    for (const g of groups) {
+      for (const d of g) {
+        const key = d.artifacts.map((a) => a.url).join('|') || d.id;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        out.push(d);
+      }
+    }
+    return out;
+  }
+
   byProject(projectId: string): Deliverable[] {
     return this._items().filter((d) => d.projectId === projectId);
   }
@@ -238,6 +296,34 @@ export class DeliverablesService {
   }
 }
 
+/**
+ * 文件台账记录 → 右栏可渲染的 `Deliverable`。
+ *
+ * 右栏组件吃的是 `Deliverable[]`,这里做一次投影,
+ * 模板层一行都不用改(用户要求「直接更换为新的一套逻辑」,不是重写 UI)。
+ */
+function toDeliverable(f: SessionFile): Deliverable {
+  const at = f.deliveredAt ? new Date(f.deliveredAt).toISOString() : '';
+  return {
+    id: f.id,
+    agentId: f.agentId,
+    title: f.name,
+    filename: f.name,
+    summary: f.uploadedBy === 'user' ? '用户上传' : '容器产出',
+    artifacts: [
+      {
+        name: f.name,
+        url: f.url,
+        type: f.mimeType,
+        ...(f.storageKey ? { source: f.storageKey } : {}),
+      },
+    ],
+    tags: [f.uploadedBy],
+    deliveredAt: at,
+    state: 'delivered',
+  };
+}
+
 /**
  * 从云盘 URL 里取文件名(用于「只有 URL、成果表里没有」的条目)。
  *

+ 61 - 114
src/app/core/device.service.spec.ts

@@ -171,26 +171,37 @@ describe('DeviceService', () => {
       expect(svc.normalize('/chat/a')).toBe('/mobile/chat/a');
     });
 
-    it('resetToAuto 清除偏好并回到自动判定', () => {
+    /*
+     * 🔴 2026-11-10 用户裁决:去掉手动切换,纯自动识别屏幕大小。
+     *    以下三条用例随之改写 —— 原断言的是「手动偏好持久化」,现已废弃。
+     */
+    it('resetToAuto 重新按屏幕判定(不再读偏好)', () => {
       const svc = make(DESKTOP_UA);
+      /*
+       * ⚠️ `make()` 构造完会还原 window.innerWidth,所以这里不能断言具体是哪一端,
+       *    只能断言「回到了自动判定」—— 即 isForced 被清掉。
+       */
       svc.force('mobile');
       expect(svc.mode()).toBe('mobile');
-      expect(svc.resetToAuto()).toBe('desktop');
+      expect(svc.isForced()).toBe(true);
+      svc.resetToAuto();
+      expect(svc.isForced()).toBe(false);
     });
 
-    it('偏好键名固定为 fmode_ui_mode_override(FR-16 迁移兼容)', () => {
+    it('🔴 不再写入手动偏好键(自动识别模式)', () => {
       const svc = make(DESKTOP_UA);
       svc.force('mobile');
-      expect(localStorage.getItem('fmode_ui_mode_override')).toBe('mobile');
+      expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
       svc.resetToAuto();
       expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
     });
 
-    it('旧键能正确读取(AC-16.1:不报错、不串端)', () => {
+    it('🔴 旧的偏好键被忽略(屏幕尺寸说了算)', () => {
       localStorage.setItem('fmode_ui_mode_override', 'mobile');
       const svc = make(DESKTOP_UA, { keepStorage: true });
-      expect(svc.mode()).toBe('mobile');
-      expect(svc.isForced()).toBe(true);
+      // 宽屏 + 桌面 UA → 桌面端,不被旧偏好带偏
+      expect(svc.mode()).toBe('desktop');
+      expect(svc.isForced()).toBe(false);
     });
 
     it('非法偏好值被安全忽略(回落自动判定)', () => {
@@ -226,132 +237,68 @@ describe('DeviceService', () => {
 });
 
 /**
- * 跨端提示(2026-11-10 用户裁决)
+ * 纯自动识别(2026-11-10 用户裁决,2026-11-11 合并确认)
  *
- * 缩放到另一端时**自动判断并提示**,点确认才切;拖拽窗口要有防抖,不能反复闪。
+ * 「不需要手动切换,自动识别比例和屏幕大小」→ `detect()` **视口优先**:
+ * 电脑上把窗口拖窄就是移动端,横屏手机宽度够仍算移动端。
+ * 跨端提示 / 手动兜底已随裁决移除,这里锁住新口径的边界。
  */
-describe('DeviceService · 跨端提示', () => {
-  const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
-
-  let listeners: ((e: unknown) => void)[] = [];
-  const origMatch = window.matchMedia;
-  const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
+describe('DeviceService · 纯自动识别(视口优先)', () => {
   const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
-
-  beforeEach(() => {
-    localStorage.clear();
-    listeners = [];
-    Object.defineProperty(navigator, 'userAgent', { value: DESKTOP_UA, configurable: true });
-    Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true });
-    window.matchMedia = ((_q: string) =>
-      ({
-        matches: false,
-        media: _q,
-        addEventListener: (_: string, cb: (e: unknown) => void) => listeners.push(cb),
-        removeEventListener: () => undefined,
-      }) as unknown as MediaQueryList) as typeof window.matchMedia;
-  });
+  const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
+  const origMatch = window.matchMedia;
 
   afterEach(() => {
-    if (origMatch) window.matchMedia = origMatch;
-    if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
     if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
-    jasmine.clock().uninstall();
+    if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
+    window.matchMedia = origMatch;
     localStorage.clear();
   });
 
-  function setWidth(w: number): void {
-    Object.defineProperty(window, 'innerWidth', { value: w, configurable: true });
+  function make(ua: string, width: number): DeviceService {
+    localStorage.clear();
+    Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true });
+    Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
+    window.matchMedia = ((q: string) =>
+      ({ matches: false, media: q, addEventListener: () => undefined, removeEventListener: () => undefined }) as unknown as MediaQueryList) as typeof window.matchMedia;
+    return new DeviceService();
   }
 
-  it('宽视口下桌面端 → 不提示', () => {
-    setWidth(1440);
-    const svc = new DeviceService();
-    svc.watchViewport(() => undefined);
-    expect(svc.suggestedMode()).toBeNull();
-  });
+  const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
+  const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148';
 
-  it('🔴 窗口缩到 768 以下(桌面端)→ 建议切移动端', () => {
-    setWidth(1440);
-    const svc = new DeviceService();
-    svc.watchViewport(() => undefined);
-    setWidth(600);
-    svc['evaluate']();
-    expect(svc.suggestedMode()).toBe('mobile');
+  it('🔴 桌面 UA + 窄窗口 → 移动端(UA 描述设备,不描述这个窗口)', () => {
+    const svc = make(DESKTOP_UA, NARROW_PX - 1);
+    expect(svc.detect()).toBe('mobile');
+    expect(svc.mode()).toBe('mobile');
+    expect(svc.entryPath()).toBe('/mobile/messages');
   });
 
-  it('🔴 防抖:短时间内多次变化只生效一次', () => {
-    jasmine.clock().install();
-    setWidth(1440);
-    const svc = new DeviceService();
-    const onChange = jasmine.createSpy('onChange');
-    svc.watchViewport(onChange);
-
-    // 连续三次跨断点(模拟拖拽)
-    listeners.forEach((cb) => cb({}));
-    listeners.forEach((cb) => cb({}));
-    listeners.forEach((cb) => cb({}));
-    expect(onChange).not.toHaveBeenCalled();
-
-    jasmine.clock().tick(DeviceService.DEBOUNCE_MS + 1);
-    expect(onChange).toHaveBeenCalledTimes(1);
+  it('桌面 UA + 宽窗口 → 桌面端', () => {
+    const svc = make(DESKTOP_UA, NARROW_PX);
+    expect(svc.detect()).toBe('desktop');
   });
 
-  it('🔴 手动切换过仍会提示(手动只代表「这次选哪端」,不代表「别再问」)', () => {
-    setWidth(1440);
-    const svc = new DeviceService();
-    svc.force('desktop');
-    svc.watchViewport(() => undefined);
-    setWidth(600);
-    svc['evaluate']();
-    expect(svc.suggestedMode()).withContext('不能因为偏好锁死就永不提示').toBe('mobile');
+  it('🔴 手机 UA 横屏(宽度已够)→ 仍是移动端', () => {
+    expect(make(IPHONE_UA, 844).detect()).toBe('mobile');
   });
 
-  it('🔴 切到移动端后把窗口放大 → 应提示切回桌面端(用户实测的反向场景)', () => {
-    setWidth(1440);
-    const svc = new DeviceService();
-    svc.watchViewport(() => undefined);
-
-    // ① 缩窄 → 建议切移动端
-    setWidth(600);
-    svc['evaluate']();
-    expect(svc.suggestedMode()).toBe('mobile');
-
-    // ② 用户点了「切换」→ 偏好写成 mobile
-    svc.force('mobile');
-    expect(svc.suggestedMode()).withContext('刚切完,当前端与视口一致,无需再提示').toBeNull();
-
-    // ③ 窗口放大回桌面尺寸 → 必须提示切回桌面端
-    setWidth(1440);
-    svc['evaluate']();
-    expect(svc.suggestedMode()).toBe('desktop');
-  });
-
-  it('点「稍后」后本次不再提示,但重新跨断点仍会提示', () => {
-    setWidth(1440);
-    const svc = new DeviceService();
-    svc.watchViewport(() => undefined);
-    setWidth(600);
-    svc['evaluate']();
-    expect(svc.suggestedMode()).toBe('mobile');
-
-    svc.dismissSuggestion();
-    expect(svc.suggestedMode()).toBeNull();
-
-    svc['evaluate']();
-    expect(svc.suggestedMode()).withContext('再次跨断点应重新提示').toBe('mobile');
+  it('移动端把窗口拖宽 → 重新判定为桌面端', () => {
+    const svc = make(DESKTOP_UA, NARROW_PX - 1);
+    expect(svc.mode()).toBe('mobile');
+    Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true });
+    const r = svc.recheck();
+    expect(r.changed).toBe(true);
+    expect(r.mode).toBe('desktop');
+    expect(svc.entryPath()).toBe('/desktop/chat');
   });
 
-  it('移动端 + 宽视口 → 建议切桌面端', () => {
-    Object.defineProperty(navigator, 'userAgent', {
-      value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
-      configurable: true,
-    });
-    setWidth(390);
-    const svc = new DeviceService();
-    svc.watchViewport(() => undefined);
-    setWidth(1200);
-    svc['evaluate']();
-    expect(svc.suggestedMode()).toBe('desktop');
+  it('🔴 不再读写手动偏好键', () => {
+    const svc = make(DESKTOP_UA, 1440);
+    svc.force('mobile');
+    expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
+    svc.resetToAuto();
+    expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
+    expect(svc.mode()).toBe('desktop');
   });
 });

+ 78 - 152
src/app/core/device.service.ts

@@ -9,33 +9,53 @@ const MODE_KEY = 'fmode_ui_mode_override';
 export const NARROW_PX = 768;
 
 /**
- * 依据 UA + 视口推断该用哪端(纯函数,2026-11-10 导出)。
+ * 推断当前该用哪端(纯函数,2026-11-10 导出)。
  *
  * 🔴 抽成导出的纯函数是**为了消除重复**:`app.routes.ts` 曾逐字复制一份,
  *    两处分支一旦不同步,就会出现「登录页判移动、入口判桌面」。
  *    现在路由与 `DeviceService` 都调这一个。
+ *
+ * ## 2026-11-10 用户裁决:去掉手动切换,纯自动识别
+ *
+ * 用户原话:「不需要当前还有一个页面手动切换,而是自动识别它的比例和屏幕大小,
+ * 进行自动切换效果。」
+ *
+ * 判定顺序 —— **视口尺寸优先,UA 兜底**:
+ *   ① 视口窄于 `NARROW_PX` → 移动端
+ *   ② 宽度够但 UA 明确是手机(横屏手机 innerWidth 可达 844)→ 仍是移动端
+ *   ③ 无窗口环境(SSR / 测试)→ 回落 UA 判定
+ *   ④ 其余 → 桌面端
+ *
+ * 🔴 为什么视口优先于 UA:
+ *    UA 描述的是**设备**,不是**当前这个窗口**。用户在电脑上把窗口拖窄、
+ *    或在平板上分屏,UA 仍然是桌面/平板,但可用宽度只有手机那么宽 ——
+ *    此时应该给移动端布局,而不是硬塞桌面端(用户反馈的「切换异常」)。
  */
 export function detectUiMode(): UiMode {
-  if (typeof navigator === 'undefined') return 'desktop';
-  const ua = navigator.userAgent;
-
-  // ① 平板(含 iPadOS 伪装的桌面 UA)→ 桌面端
-  if (/iPad|Tablet|PlayBook|Silk/i.test(ua)) return 'desktop';
-  if (/Android/i.test(ua) && !/Mobile/i.test(ua)) return 'desktop';
-
-  // ② 手机 UA → 移动端
-  if (/iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(ua)) {
-    return 'mobile';
+  if (typeof window !== 'undefined' && window.innerWidth > 0) {
+    /* ① 视口宽度 —— 主判据 */
+    if (window.innerWidth < NARROW_PX) return 'mobile';
+    /* ② 横屏手机:宽度够但仍是手机 */
+    if (isPhoneUa()) return 'mobile';
+    return 'desktop';
   }
 
-  // ③ 触屏设备且窗口窄 → 移动端
-  const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
-  if (coarse && typeof window !== 'undefined' && window.innerWidth < NARROW_PX) return 'mobile';
+  /* ③ 无窗口环境(SSR / 单测未铺视口)→ 回落 UA */
+  if (typeof navigator === 'undefined') return 'desktop';
+  if (isPhoneUa()) return 'mobile';
 
-  // ④ 默认桌面端
+  // ④ 平板(含 iPadOS 伪装的桌面 UA)→ 桌面端
   return 'desktop';
 }
 
+/** UA 是否明确是手机 */
+function isPhoneUa(): boolean {
+  if (typeof navigator === 'undefined') return false;
+  return /iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(
+    navigator.userAgent,
+  );
+}
+
 /**
  * 当前视口是不是「窄屏」(该给移动端)。
  *
@@ -53,128 +73,46 @@ export function isNarrowViewport(): boolean {
  * 依据 ADR-004(双端双套路由树)+ SPEC FR-01 / FR-16。约束:
  *   · 端判定**一次性**发生在入口页,之后不随窗口缩放变化(避免布局跳变)
  *   · `normalize()` 是**旧路径重定向的唯一事实源**(FR-16 AC-16.2 引用的清单实现在此)
- *   · 手动切端写 `localStorage['fmode_ui_mode_override']`;「恢复自动识别」清除该键
  *
- * 🔴 2026-11-10 变更:端**仍然不会静默跳变**,但新增了「缩放到另一端时提示」——
- *    `watchViewport()` 在跨断点时算出 `suggestedMode`,宿主弹提示,
- *    **用户点确认**才 `switchMode()`。这样既满足「不跳变」,
- *    又不会出现「窗口已经窄到装不下却还硬撑桌面布局」。
+ * ## 2026-11-10 用户裁决:去掉手动切换,纯自动识别
+ *
+ * 因此:
+ *   · **不再读写** `fmode_ui_mode_override`(保留 `force()` 仅为兼容旧调用点)
+ *   · 判定改为 **视口尺寸优先**:窄屏(< 768px)走移动端,否则桌面端
+ *   · 跨端切换由双端外壳自行处理:**防抖后静默跳转,不弹确认**
  */
 @Injectable({ providedIn: 'root' })
 export class DeviceService {
-  /**
-   * 当前端。
-   *
-   * 🔴 **优先读手动偏好**(`fmode_ui_mode_override`),无偏好才自动识别。
-   *    只取 `detect()` 会导致「手动切端刷新即丢失」——手机 UA 用户切到桌面端后,
-   *    刷新又被打回移动端。偏好持久化的意义就在于跨刷新保持(FR-10 AC-10.1)。
-   */
-  private readonly _mode = signal<UiMode>(this.readOverride() ?? this.detect());
+  /** 当前端 —— 纯自动识别,不再读手动偏好 */
+  private readonly _mode = signal<UiMode>(this.detect());
   readonly mode = this._mode.asReadonly();
 
-  /** 是否处于手动强制(覆盖了自动识别) */
-  readonly isForced = signal(this.hasOverride());
+  /** 兼容字段:自动识别模式下永远为 false */
+  readonly isForced = signal(false);
 
   /**
-   * 跨端提示的**建议目标端**(null = 不需要提示)。
-   *
-   * 🔴 2026-11-10 用户裁决:缩放到另一端时**自动判断并提示**,点确认才切。
+   * 依据 UA + 视口推断当前该用哪端(纯自动识别)。
    *
-   * 规则:
-   *   · 手动强制过(`isForced()`)→ 永不提示,尊重用户选择
-   *   · 当前在桌面端、视口窄于 `NARROW_PX` → 建议切到移动端
-   *   · 当前在移动端、视口宽于等于 `NARROW_PX` → 建议切到桌面端
-   *   · 否则 null
+   * 判定口径全部收敛在 `detectUiMode()`,这里只做实例代理。
    */
-  readonly suggestedMode = signal<UiMode | null>(null);
-
-  /** resize → 判定的防抖时长;拖拽窗口时只触发一次 */
-  static readonly DEBOUNCE_MS = 250;
-
-  private mq: MediaQueryList | null = null;
-  private timer: ReturnType<typeof setTimeout> | null = null;
-  private mqListener: (() => void) | null = null;
-  private onChange: (() => void) | null = null;
-
+  detect(): UiMode {
+    return detectUiMode();
+  }
 
   /**
-   * 开始监听视口变化。
+   * 窗口尺寸变化时重新判定(2026-11-10)。
    *
-   * 🔴 每次调用都**替换**回调,而不是「已存在就跳过」(2026-11-10 修正)。
-   *    外壳是切换端时重建的:桌面壳注册过监听后,切到移动端时新外壳再调用
-   *    `watchViewport` 会被旧的幂等判断挡掉 → 回调仍指向**已销毁的桌面壳**,
-   *    移动端永远收不到「切回桌面」的提示(用户实测)。
+   * 用户切换浏览器窗口大小、旋转设备、分屏时调用。
+   * 返回 `true` 表示端发生了变化,调用方应导航到对应落点。
    *
-   *    用 `matchMedia` 而不是裸 `resize`:change 只在跨断点时触发,
-   *    不会有拖拽窗口时每像素一次的高频回调;外层仍加防抖兜底。
+   * ⚠️ 刻意**不自动导航**:只在调用方明确要切(例如入口页或外壳)时才用,
+   *    避免用户正在输入时页面突然跳走。
    */
-  watchViewport(onChange: () => void): void {
-    if (typeof window === 'undefined') return;
-    this.teardown();
-    this.onChange = onChange;
-    this.mq = window.matchMedia(`(max-width: ${NARROW_PX - 1}px)`);
-    this.mqListener = () => this.scheduleEvaluate();
-    this.mq.addEventListener('change', this.mqListener);
-    this.evaluate();
-  }
-
-  /** 停止监听(宿主销毁时调用) */
-  stopWatching(): void {
-    this.teardown();
-  }
-
-  /** 拆掉监听与定时器(幂等) */
-  private teardown(): void {
-    if (this.mq && this.mqListener) {
-      this.mq.removeEventListener('change', this.mqListener);
-    }
-    this.mq = null;
-    this.mqListener = null;
-    this.onChange = null;
-    if (this.timer) {
-      clearTimeout(this.timer);
-      this.timer = null;
-    }
-  }
-
-  /** 依据 UA + 视口推断当前该用哪端 */
-  detect(): UiMode {
-    return detectUiMode();
-  }
-
-  /** 用户点了「稍后/关闭」→ 本次不再提示;下次跨断点仍会提示 */
-  dismissSuggestion(): void {
-    this.suggestedMode.set(null);
-  }
-
-  private scheduleEvaluate(): void {
-    if (this.timer) clearTimeout(this.timer);
-    this.timer = setTimeout(() => {
-      this.timer = null;
-      this.evaluate();
-      /* 回调可能已随宿主销毁被清空 —— 调用前判一次,别炸在已销毁组件上 */
-      this.onChange?.();
-    }, DeviceService.DEBOUNCE_MS);
-  }
-
-  /** 按当前视口与当前端算出建议值 */
-  private evaluate(): void {
-    /*
-     * 🔴 手动切换过**也要继续提示**(2026-11-10 用户实测修正)。
-     *
-     * 原实现这里有一句 `if (isForced()) return` —— 用户点过一次「切换」后,
-     * 偏好被写成固定值,从此**再也不会提示**:
-     *   桌面 → 切到移动端 → 把窗口放大回桌面尺寸 → 界面仍是移动端且毫无提示,
-     *   用户以为功能坏了,只能去设置里找「恢复自动识别」。
-     *
-     * 正确语义:手动切换只是「这次我选了哪端」,不代表「以后别再问我」。
-     * 跨断点照样提示,点一下就能切回去。
-     */
-    const narrow = isNarrowViewport();
-    const cur = this._mode();
-    if (cur === 'desktop' && narrow) this.suggestedMode.set('mobile');
-    else if (cur === 'mobile' && !narrow) this.suggestedMode.set('desktop');
-    else this.suggestedMode.set(null);
+  recheck(): { changed: boolean; mode: UiMode } {
+    const next = this.detect();
+    const changed = next !== this._mode();
+    if (changed) this._mode.set(next);
+    return { changed, mode: next };
   }
 
   /** 入口页落点 */
@@ -182,23 +120,22 @@ export class DeviceService {
     return this._mode() === 'mobile' ? '/mobile/messages' : '/desktop/chat';
   }
 
-  /** 强制某端(写偏好) */
+  /**
+   * 强制某端。
+   *
+   * @deprecated 2026-11-10 用户裁决去掉手动切换。保留方法只为不破坏既有调用点,
+   *             **不再写入 localStorage**,仅当次生效,刷新后回到自动识别。
+   */
   force(mode: UiMode): void {
-    try {
-      localStorage.setItem(MODE_KEY, mode);
-    } catch {
-      /* 忽略 */
-    }
     this._mode.set(mode);
     this.isForced.set(true);
-    /*
-     * 切完立刻重算:此刻当前端已与视口一致,建议值应变回 null,
-     * 否则提示会继续挂着(「已切过去却还在问要不要切」)。
-     */
-    this.evaluate();
   }
 
-  /** 恢复自动识别(清除偏好) */
+  /**
+   * 恢复自动识别。
+   *
+   * @deprecated 2026-11-10 起永远处于自动识别,此方法只做重新判定。
+   */
   resetToAuto(): UiMode {
     try {
       localStorage.removeItem(MODE_KEY);
@@ -208,15 +145,18 @@ export class DeviceService {
     const mode = this.detect();
     this._mode.set(mode);
     this.isForced.set(false);
-    /* 回到自动后当前端已随视口,建议值应清空 */
-    this.evaluate();
     return mode;
   }
 
-  /** 切换端并返回目标落点 */
+  /**
+   * 切换端并返回目标落点(自动跳转用)。
+   *
+   * 2026-11-10:不再写持久化偏好 —— 端由屏幕尺寸决定,
+   * 手动「钉住」会让用户在小屏看到桌面布局、很别扭。
+   */
   switchMode(): { mode: UiMode; path: string } {
     const next: UiMode = this._mode() === 'mobile' ? 'desktop' : 'mobile';
-    this.force(next);
+    this._mode.set(next);
     return { mode: next, path: next === 'mobile' ? '/mobile/messages' : '/desktop/chat' };
   }
 
@@ -280,18 +220,4 @@ export class DeviceService {
   private pathWithId(base: string, id: string | undefined, qs: string): string {
     return id ? `/${this._mode()}${base}/${id}${qs}` : `/${this._mode()}${base}${qs}`;
   }
-
-  /** 读取手动端偏好;无有效值返回 null */
-  private readOverride(): UiMode | null {
-    try {
-      const v = localStorage.getItem(MODE_KEY);
-      return v === 'desktop' || v === 'mobile' ? v : null;
-    } catch {
-      return null;
-    }
-  }
-
-  private hasOverride(): boolean {
-    return this.readOverride() !== null;
-  }
 }

+ 49 - 1
src/app/core/group-send.service.ts

@@ -2,6 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
 import { ChatSessionService } from './chat-session.service';
 import { ChatSession } from './chat-session';
 import { ChatService, PendingFile } from './chat.service';
+import { isOversize, oversizeMessage } from './upload-limits';
 import { DispatchAnchor, GroupLiveService } from './group-live.service';
 import { GroupChatService } from './group-chat.service';
 import { GroupStoreService } from './group-store.service';
@@ -47,12 +48,23 @@ export class GroupSendService {
   private readonly _uploadPct = signal(0);
   readonly uploadPct = this._uploadPct.asReadonly();
 
+  /** 附件问题(超限 / 上云失败),由群聊页展示 */
+  private readonly _error = signal<string | null>(null);
+  readonly error = this._error.asReadonly();
+
   private readonly _sending = signal(false);
   readonly sending = this._sending.asReadonly();
 
   /** 暂存文件(不发送);重复选择追加 */
   stageFiles(files: FileList | File[]): void {
-    const list = Array.from(files);
+    const all = Array.from(files);
+    if (all.length === 0) return;
+    /* 大小拦截(2026-11-10):与单聊同规则,超限项不进暂存区 */
+    const list = all.filter((f) => !isOversize(f));
+    const rejected = all.filter((f) => isOversize(f));
+    if (rejected.length > 0) {
+      this._error.set(rejected.map(oversizeMessage).join(';'));
+    }
     if (list.length === 0) return;
     const added: PendingFile[] = list.map((file, i) => ({
       id: `gpf-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 6)}`,
@@ -126,6 +138,42 @@ export class GroupSendService {
       const failed: string[] = [];
       const anchors: Record<string, DispatchAnchor> = {};
 
+      /*
+       * ① 先上云盘(2026-11-10 补)。
+       *
+       * 群聊此前**完全不上云**:`sendFileToAgent` 只发 Matrix,
+       * 用户在大群里发的文件换台设备就没了,且没有任何提示。
+       * 用户裁决「文件都要确保自动上云」,所以这里补一次 S3 双写。
+       *
+       * 放在成员循环**外面**:文件只有一份,按成员各传一遍是浪费
+       * (单聊上传之所以在每个房间发 Matrix,是因为 Matrix 侧必须一人一份)。
+       */
+      const cloudUrls: string[] = [];
+      for (const pf of files) {
+        try {
+          const up = await this.chat.uploadToCloud(pf.file);
+          cloudUrls.push(up.publicUrl);
+          /*
+           * 登记到文件台账(2026-11-10):群聊的 threadRoot 用群会话 id,
+           * 这样群里传的文件也只在**这个群**的右侧栏出现,不串到单聊。
+           */
+          try {
+            await this.chat.registerSessionFile(session.id, pf.file, up, plan.targets[0]);
+          } catch (e) {
+            this._error.set(
+              `「${pf.file.name}」已存入云盘,但未记入群文件列表:${e instanceof Error ? e.message : '登记失败'}`,
+            );
+          }
+        } catch (e) {
+          this._error.set(
+            `「${pf.file.name}」未能存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
+          );
+        }
+      }
+      if (cloudUrls.length > 0) {
+        this.sessions.addDeliverableUrl(session.id, ...cloudUrls);
+      }
+
       // 串行发送:并发会触发 connectSession 互相 disconnect
       for (const id of plan.targets) {
         let baseline: string | null = null;

+ 48 - 0
src/app/core/message-merge.spec.ts

@@ -132,3 +132,51 @@ describe('isMergeableBody', () => {
     expect(isMergeableBody(m('a1', 'agent', 'x', { taskId: 't1' }))).toBe(false);
   });
 });
+
+/*
+ * 🔴 2026-11-10 移动端实测:容器用多行 ✅/❌ 列表输出结论时,
+ *    每条各自成块 → 一屏出现一堆小卡片。结果 emoji 应作为正文参与合并。
+ */
+describe('结果 emoji 行参与合并', () => {
+  const agent = (id: string, text: string, ts: number) => ({
+    id,
+    conversationId: 'c1',
+    sender: 'agent' as const,
+    kind: 'text' as const,
+    text,
+    ts: new Date(ts).toISOString(),
+    state: 'sent' as const,
+  });
+
+  it('连续的 ✅/❌ 结论行合并成一段', () => {
+    const out = mergeConsecutiveBodies([
+      agent('a', '✅ 成果表有记录(05:34:19Z)', 1000),
+      agent('b', '❌ 但回复正文没有任何 https', 2000),
+      agent('c', '❌ 正文还有残留:「本地路径」', 3000),
+    ]);
+    expect(out.length).toBe(1);
+    expect(out[0].text).toContain('成果表有记录');
+    // 合并后用空行分隔(mergeBodies 的既定规则)
+    expect(out[0].text).toContain('https');
+    expect(out[0].text).toContain('本地路径');
+  });
+
+  it('纯工具 emoji 行仍然不参与合并(保持原行为)', () => {
+    const out = mergeConsecutiveBodies([
+      agent('a', '💻 terminal', 1000),
+      agent('b', '🔎 Searching files', 2000),
+    ]);
+    // 不可合并的消息**原样保留**(不丢弃),所以仍是 2 条
+    expect(out.length).toBe(2);
+    expect(out[0].mergedIds).toEqual(['a']);
+    expect(out[1].mergedIds).toEqual(['b']);
+  });
+
+  it('正文与结论混排也合并', () => {
+    const out = mergeConsecutiveBodies([
+      agent('a', '我来检查一下结果。', 1000),
+      agent('b', '✅ 全部通过', 2000),
+    ]);
+    expect(out.length).toBe(1);
+  });
+});

+ 18 - 1
src/app/core/message-merge.ts

@@ -63,8 +63,25 @@ export function isMergeableBody(m: Message): boolean {
   return true;
 }
 
-/** 单行正文是否带终端活动标记(💻 terminal / 🔎 Searching …) */
+/**
+ * 单行正文是否带终端活动标记(💻 terminal / 🔎 Searching …)
+ *
+ * 🔴 结果 emoji(✅❌⚠️)**不算**终端标记(2026-11-10 移动端实测)。
+ *
+ * 容器常在一条回复里用多行 emoji 列表输出结论:
+ * ```
+ * ✅ 成果表有记录
+ * ❌ 但回复正文没有任何 https
+ * ❌ 正文还有残留
+ * ```
+ * 旧规则把它们当成终端活动 → 每条各自成块 → 移动端一屏出现**一堆小卡片**
+ * (用户截图实测)。
+ *
+ * 结果 emoji 表示「结论」,本来就该作为正文参与合并、读成一段。
+ */
 function hasTerminalMarker(text: string): boolean {
+  // 以结果 emoji 开头 → 是结论,不是执行过程
+  if (/^[*·•\-]?\s*[✅❌⚠️✔✔️❎🚫🛑⛔🎉🏁]/u.test(text)) return false;
   // 以 emoji 或动词开头 → 视为执行过程
   if (/^[*·•\-]?\s*[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text)) return true;
   return /^[*·•\-]?\s*(?:terminal|Searching|Reading|Writing|Running|Executing)\b/i.test(text);

+ 41 - 0
src/app/core/message-segments.spec.ts

@@ -117,6 +117,47 @@ describe('message-segments', () => {
       expect(toolActivitySummary("💻 terminal\\n```\\nls\\n```")).toContain("terminal");
     });
   });
+
+  /*
+   * 2026-11-10 实测原文(lj-test 容器在临时房间里的真实输出)。
+   *
+   * 容器的进度消息是 `m.text`,且**同一个气泡里堆了多行工具行**,
+   * 部分还带 `(×2)`、`(×3)` 这种折叠计数后缀。旧实现在「每一行都是工具行」
+   * 时才整条折叠,只要混进一行中文叙述就整条进正文 —— 用户看到满屏 `🐍 Running code…`。
+   */
+  describe('容器进度消息(2026-11-10 实测原文)', () => {
+    it('多行 🐍 Running code 堆叠 → 是终端活动', () => {
+      const txt = [
+        '* 🐍 Running code from hermes_tools import terminal # H...',
+        '🐍 Running code from hermes_tools import terminal r=t...',
+      ].join('\n');
+      expect(isToolActivityMessage(txt)).toBeTrue();
+    });
+
+    it('带折叠计数 (×2) (×3) 仍识别为终端活动', () => {
+      const txt = [
+        '* 🐍 Running code from hermes_tools import terminal r=t...',
+        '🐍 Running code from hermes_tools import search_files...',
+        '🐍 Running code from hermes_tools import read_file, s...',
+        '🐍 Running code from hermes_tools import search_files...',
+      ].join('\n');
+      expect(isToolActivityMessage(txt)).toBeTrue();
+    });
+
+    it('🔴 工具行 + 一句中文叙述混排 → 仍是终端活动(整条进过程区,不刷屏正文)', () => {
+      const txt = [
+        '🐍 Running code from hermes_tools import terminal r=t...',
+        '现在我来检查 drain 脚本、配置里与日志和消息类型有关的部分。',
+        '🐍 Running code from hermes_tools import terminal, re...',
+      ].join('\n');
+      expect(isToolActivityMessage(txt)).toBeTrue();
+    });
+
+    it('🔴 纯粹的中文分析仍是正文(不能因为常和工具混排就被误折)', () => {
+      const txt = '我发现 ArtifactSyncService 这条链路其实是通用的,只不过之前从来没有容器把 artifacts 写进来过,所以右栏一直是空的。';
+      expect(isToolActivityMessage(txt)).toBeFalse();
+    });
+  });
 });
 
 describe('emoji 工具行(2026-11-06 补齐)', () => {

+ 35 - 9
src/app/core/message-segments.ts

@@ -152,6 +152,24 @@ function isToolActivityLine(line: string): boolean {
  * 把「一条纯终端活动消息」整体识别出来(用于**整条折叠**,不做正文拆分)。
  *
  * 返回 `true` 表示这条消息全是终端活动,UI 可整条收进折叠组。
+ *
+ * ## 2026-11-10 修正:混排消息也算终端活动
+ *
+ * 实测(lj-test 容器真实输出):容器一条消息里经常是
+ *
+ * ```
+ * 🐍 Running code from hermes_tools import terminal r=t...
+ * 现在我来检查 drain 脚本、配置里与日志和消息类型有关的部分。
+ * 🐍 Running code from hermes_tools import terminal, re...
+ * ```
+ *
+ * 旧规则要求**每一行**都是工具行才整条折叠,只要夹一句中文就整条进正文,
+ * 用户看到满屏 `🐍 Running code…` 刷屏(截图实测)。
+ *
+ * 新规则:**工具行占比过半**即视为进度消息。
+ *   · 有 1 行工具 + 1 行中文   → 占比 50%,判为正文(不折)
+ *   · 有 2 行工具 + 1 行中文   → 占比 67%,判为进度(折叠)
+ * 这样既能压住刷屏,又不会把「正文里带一次工具调用」的正常回答折掉。
  */
 export function isToolActivityMessage(text: string | undefined): boolean {
   if (!text) return false;
@@ -160,17 +178,25 @@ export function isToolActivityMessage(text: string | undefined): boolean {
   // 去掉 ``` 围栏后,剩下的每一行都得是终端活动或纯命令
   const body = lines.filter((l) => !/^```/.test(l.trim()));
   if (body.length === 0) return true;
-  // 只要有一行是「像人话的长段落」,就不是纯终端消息
-  return body.every((l) => {
-    const t = l.trim();
-    if (isToolActivityLine(t)) return true;
+
+  const isToolLine = (t: string): boolean =>
+    isToolActivityLine(t) ||
     // 命令本身:短、无中文句号、常见命令/路径特征
-    return (
-      t.length <= 120 &&
+    (t.length <= 120 &&
       !/。$/.test(t) &&
-      (/^[\w./~$*-]/.test(t) || /^[a-zA-Z_]+=/.test(t))
-    );
-  });
+      (/^[\w./~$*-]/.test(t) || /^[a-zA-Z_]+=/.test(t)));
+
+  const toolCount = body.filter((l) => isToolLine(l.trim())).length;
+
+  // 全是工具行 → 旧的严格口径(最常见,保持原行为)
+  if (toolCount === body.length) return true;
+
+  /*
+   * 混排:工具行必须**过半**才算进度消息。
+   * 严格大于一半(而非 >=)是为了让「1 工具 + 1 叙述」这种对半分的情况
+   * 留在正文 —— 宁可漏折,不可误折(本文件的既定原则)。
+   */
+  return toolCount * 2 > body.length;
 }
 
 /** 从终端活动消息里提取一句话摘要(折叠头显示什么) */

+ 78 - 55
src/app/core/s3-link.spec.ts

@@ -1,93 +1,116 @@
-import { extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
+import {
+  extractDeliveredLinks,
+  extractS3Links,
+  hasDeliveryBlock,
+  isUserUploadUrl,
+  stripLinks,
+} from './s3-link';
 
 /**
  * 单测:从消息正文提取云盘链接(2026-11-09)
- *
- * 背景:容器产出的成果不发附件,只把 URL 写在正文里。
  */
-describe('extractS3Links', () => {
-  it('提取正文里的 s3 链接', () => {
-    const text = '公网 URL:\n\nhttps://s3.fmode.cn/user/x/c-result.md\n\n以上。';
-    expect(extractS3Links(text)).toEqual(['https://s3.fmode.cn/user/x/c-result.md']);
-  });
 
-  it('一行里多个链接都能提取', () => {
-    const text = 'A https://s3.fmode.cn/a.md B https://s3.fmode.cn/b.xlsx';
-    expect(extractS3Links(text)).toEqual([
-      'https://s3.fmode.cn/a.md',
-      'https://s3.fmode.cn/b.xlsx',
-    ]);
+const U = 'https://s3.fmode.cn/user/x/deliverables/202611/a-1.md';
+
+describe('extractS3Links', () => {
+  it('提取纯 URL 行', () => {
+    expect(extractS3Links(`做好了\n${U}`)).toEqual([U]);
   });
 
-  it('🔴 去掉句子末尾的中文标点', () => {
-    expect(extractS3Links('文件在 https://s3.fmode.cn/x/a.md。')).toEqual([
-      'https://s3.fmode.cn/x/a.md',
-    ]);
-    expect(extractS3Links('(见 https://s3.fmode.cn/x/a.md)')).toEqual([
-      'https://s3.fmode.cn/x/a.md',
-    ]);
+  it('提取 markdown 链接里的 URL', () => {
+    expect(extractS3Links(`[下载](${U})`)).toEqual([U]);
   });
 
-  it('🔴 非云盘域名不提取(避免把普通链接当成果)', () => {
-    expect(extractS3Links('官网 https://example.com/a.md')).toEqual([]);
-    expect(extractS3Links('https://matrix.fmode.cn/foo')).toEqual([]);
+  it('同一 URL 只出现一次', () => {
+    expect(extractS3Links(`${U}\n${U}`)).toEqual([U]);
   });
 
-  it('去重:同一链接出现两次只返回一次', () => {
-    const u = 'https://s3.fmode.cn/x/a.md';
-    expect(extractS3Links(`${u} 和 ${u}`)).toEqual([u]);
+  it('非云盘域名不提取', () => {
+    expect(extractS3Links('https://example.com/a.md')).toEqual([]);
   });
 
   it('空输入安全', () => {
     expect(extractS3Links('')).toEqual([]);
     expect(extractS3Links(undefined)).toEqual([]);
-    expect(extractS3Links(null)).toEqual([]);
   });
 
-  it('没有链接的普通文本返回空', () => {
-    expect(extractS3Links('收到,已经处理完毕。')).toEqual([]);
+  it('去掉行尾标点', () => {
+    expect(extractS3Links(`见 ${U}。`)).toEqual([U]);
+  });
+});
+
+describe('stripLinks', () => {
+  it('从正文去掉指定链接', () => {
+    const src = `做好了\n${U}\n请查收`;
+    const out = stripLinks(src, [U]);
+    expect(out).not.toContain(U);
+    expect(out).toContain('做好了');
+    expect(out).toContain('请查收');
+  });
+
+  it('没有链接时不改动原文', () => {
+    const src = '收到,已经处理完毕。';
+    expect(stripLinks(src, [])).toBe(src);
+  });
+
+  it('空输入安全', () => {
+    expect(stripLinks('', [U])).toBe('');
+    expect(stripLinks(undefined, [U])).toBe('');
   });
 });
 
 /**
- * stripLinks:正文隐去链接(2026-11-09 用户裁决)
+ * 容器交付段落解析(2026-11-10 取证后新增)
  *
- * 🔴 上一版的 bug 就是这里:剥掉链接后没判断剩余内容,
- *    正文只剩链接时整条消息变成空白,用户「回复的文字没有了」。
+ * 容器侧插件 `plugin-deliverable-inject` 的实际输出是:
+ *   **已上云(可直接下载)**
+ *   - https://s3.fmode.cn/xxx.md
+ *
+ * 原先期望的 `<!--fmode:files-->` 结构化声明段容器从未实现,那条分支已删除。
  */
-describe('stripLinks', () => {
-  const U = 'https://s3.fmode.cn/user/x/a.md';
+describe('extractDeliveredLinks —— 容器交付段落', () => {
+  const HEADER = '**已上云(可直接下载)**';
 
-  it('🔴 正文有内容 → 保留正文 + 摘掉链接', () => {
-    const out = stripLinks(`本周工作计划已生成。\n\n${U}`, [U]);
-    expect(out).toBe('本周工作计划已生成。');
+  it('识别交付段落', () => {
+    expect(hasDeliveryBlock(`做了个文件。\n\n${HEADER}\n- https://s3.fmode.cn/a.md`)).toBeTrue();
   });
 
-  it('🔴 正文只有链接 → 返回空串(调用方据此只显示卡片)', () => {
-    expect(stripLinks(U, [U])).toBe('');
+  it('没有交付段落时不认', () => {
+    expect(hasDeliveryBlock('这是一段普通回答,没有文件。')).toBeFalse();
+    expect(hasDeliveryBlock(undefined)).toBeFalse();
   });
 
-  it('🔴 摘掉链接后,只剩前缀的行一并删除', () => {
-    const out = stripLinks(`说明文字。\n\n**下载链接:** ${U}`, [U]);
-    expect(out).toBe('说明文字。');
-    expect(out).not.toContain('下载链接');
+  it('只取交付段落里的链接(正文其它位置的链接不算)', () => {
+    const text = [
+      '参考 https://s3.fmode.cn/examples/sample.md 这个例子。',
+      '做好了。',
+      '',
+      HEADER,
+      '- https://s3.fmode.cn/user/x/real.md',
+      '- https://s3.fmode.cn/user/x/real.xlsx',
+    ].join('\n');
+
+    expect(extractDeliveredLinks(text)).toEqual([
+      'https://s3.fmode.cn/user/x/real.md',
+      'https://s3.fmode.cn/user/x/real.xlsx',
+    ]);
   });
 
-  it('链接在中间时,前后正文都保留', () => {
-    const out = stripLinks(`开头。\n\n${U}\n\n结尾。`, [U]);
-    expect(out).toContain('开头。');
-    expect(out).toContain('结尾。');
-    expect(out).not.toContain('s3.fmode.cn');
+  it('没有交付段落时返回空数组,不误报', () => {
+    expect(extractDeliveredLinks('正文里有个 https://s3.fmode.cn/x.md')).toEqual([]);
   });
 
-  it('没有链接时不改动原文', () => {
-    const src = '收到,已经处理完毕。';
-    expect(stripLinks(src, [])).toBe(src);
+  it('交付段落里没有链接(url 为空的老问题)→ 空数组', () => {
+    expect(extractDeliveredLinks(`做完了。\n\n${HEADER}\n`)).toEqual([]);
   });
 
-  it('空输入安全', () => {
-    expect(stripLinks('', [U])).toBe('');
-    expect(stripLinks(undefined, [U])).toBe('');
+  it('交付段落里的链接去重', () => {
+    const text = `${HEADER}\n- https://s3.fmode.cn/a.md\n- https://s3.fmode.cn/a.md`;
+    expect(extractDeliveredLinks(text)).toEqual(['https://s3.fmode.cn/a.md']);
+  });
+
+  it('非云盘域名的链接不算交付', () => {
+    expect(extractDeliveredLinks(`${HEADER}\n- https://example.com/a.md`)).toEqual([]);
   });
 });
 

+ 40 - 0
src/app/core/s3-link.ts

@@ -21,6 +21,46 @@ const CLOUD_HOSTS = ['s3.fmode.cn', 'file-cloud.fmode.cn'];
 /** URL 里常见的中英文边界符,遇到即截断 */
 const TRAILING_JUNK = /[)\]>,,。;;、"'<>)】」》]+$/;
 
+/**
+ * 容器交付段落的小标题(2026-11-10 取证确认)。
+ *
+ * 容器侧插件 `plugin-deliverable-inject` 的实际输出格式是:
+ *
+ * ```
+ * **已上云(可直接下载)**
+ * - https://s3.fmode.cn/xxx.md
+ * - https://s3.fmode.cn/yyy.pdf
+ * ```
+ *
+ * 🔴 原本期望的 `<!--fmode:files [...]-->` 结构化声明段**容器从未实现**
+ *    (容器原话:输出侧不写任何结构化注释),那条解析分支已删除。
+ *    这里改为认容器**真的会写**的格式。
+ */
+const DELIVERY_HEADER = /\*\*已上云[^*]*\*\*/;
+
+/** 容器交付段落的起始标记 */
+export function hasDeliveryBlock(text: string | undefined | null): boolean {
+  return !!text && DELIVERY_HEADER.test(text);
+}
+
+/**
+ * 从容器交付段落里取链接(`- url` 列表形式)。
+ *
+ * 与 `extractS3Links` 的区别:这里**只认交付段落内的链接**,
+ * 不把正文里举例、引用、用户提问里出现的链接当成成果。
+ *
+ * @returns 交付段落内的云盘链接(保持顺序、去重);无交付段落返回空数组
+ */
+export function extractDeliveredLinks(text: string | undefined | null): string[] {
+  if (!text) return [];
+  const m = DELIVERY_HEADER.exec(text);
+  if (!m) return [];
+  // 交付段落到文末为止(容器把它放在回复末尾)
+  const tail = text.slice(m.index + m[0].length);
+  return extractS3Links(tail);
+}
+
+
 /**
  * 从一段文本里提取云盘链接。
  *

+ 41 - 0
src/app/core/upload-limits.ts

@@ -0,0 +1,41 @@
+/**
+ * 附件上传限制(2026-11-10)
+ *
+ * ## 为什么要限制
+ *
+ * 用户裁决:用户上传的文件必须能自动上云。但实际上传有两份:
+ *   ① Matrix 原生附件(容器只能读 `mxc://`,走这条)
+ *   ② S3 云盘(App 长期查看与成果归档,走这条)
+ *
+ * 文件越大,两份都传的代价越大,且 Matrix homeserver 有服务端上限
+ * (`m.upload.size`,各部署不同)。超过这个值的文件会直接被拒,
+ * 用户只会看到一条莫名其妙的失败。
+ *
+ * ## 为什么是 20MB
+ *
+ * 取自容器硬约束与历史实测(见 `docs/02-design/plans/2026-11-10-文件上云完整方案.md` §3.3):
+ * ≤20MB 走 `mxc://` 附件,容器能读;更大才需要另走正文链接通道。
+ *
+ * ## 拦截位置(两层,缺一不可)
+ *
+ * - `ChatService.stageFiles()` —— 选文件时就拦,最好的体验:不合格的根本不进来
+ * - `ChatService.uploadPending()` —— 发送前兜底,防止暂存期被替换或绕过
+ */
+export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
+
+/** 人类可读的大小,用于提示文案 */
+export function formatBytes(n: number): string {
+  if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
+  if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`;
+  return `${n}B`;
+}
+
+/** 该文件是否超出上传上限 */
+export function isOversize(file: { size: number }): boolean {
+  return file.size > MAX_UPLOAD_BYTES;
+}
+
+/** 超限提示文案(统一口径,单聊/群聊共用) */
+export function oversizeMessage(file: { name: string; size: number }): string {
+  return `「${file.name}」${formatBytes(file.size)},超过单个附件上限 ${formatBytes(MAX_UPLOAD_BYTES)},未添加`;
+}

+ 234 - 14
src/app/desktop/pages/chat/chat.ts

@@ -58,7 +58,8 @@ import { MatrixSessionService } from '../../../core/matrix-session.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { AgentModalService } from '../../../components/domain/agent-modal/agent-modal.service';
 import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
-import { AssistantTurn, turnsFromDm } from '../../../core/assistant-turn';
+import { AssistantTurn, isTurnTimedOut, TURN_TIMEOUT_MS, turnsFromDm } from '../../../core/assistant-turn';
+import { AlertService } from '../../../core/alert.service';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
 
 /**
@@ -1885,6 +1886,8 @@ export class DkChatPage implements OnDestroy {
   ngOnDestroy(): void {
     // 离开会话停掉每秒时钟(连接本身保持,与既有行为一致)
     this.stopClock();
+    // 超时看门狗也要停,否则离开页面后仍在跑
+    this.stopTimeoutWatch();
   }
 
   /**
@@ -2163,6 +2166,91 @@ export class DkChatPage implements OnDestroy {
     return out;
   });
 
+  /*
+   * 超时未响应的全局提示(2026-11-10 用户裁决)。
+   *
+   * 🔴 要求:**弹出后不自动消失,必须用户手动关闭**。
+   *    用业务层 `AlertService`(底座 toast 固定自动消失,且 ui/ 禁改)。
+   *
+   * 🔴 更正(用户反馈):不只要弹一次 —— **当前会话只要存在超时回合,就要一直显示**。
+   *
+   *   原实现用 `_timedOut` 集合永久去重,结果「关掉后再发消息超时就不弹了」。
+   *   现在改为**跟随会话状态**:
+   *     · 每次判定都看「当前会话里有没有仍在超时的回合」
+   *     · 有 → 确保提示在(`AlertService.show` 自身按标题去重,不会堆叠)
+   *     · 没有 → 自动撤掉(用户发了新消息并收到回复后,提示应当消失)
+   *     · 用户手动关闭后,若该回合仍超时,下一次 tick 会重新弹出 —— 这是**有意为之**:
+   *       问题没解决,提示就不该消失。
+   */
+  private readonly _alert = inject(AlertService);
+
+  /** 提示标题(去重键,两端口径一致) */
+  private static readonly TIMEOUT_TITLE = '容器长时间未响应';
+
+  private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
+    /*
+     * 🔴 只看**最后一轮**(2026-11-10 用户实测反馈)。
+     *
+     * 原实现扫描整个会话的历史回合,于是「打开一个老会话」时,
+     * 几天前发过一条没得到回复的消息也会立刻触发提示 --
+     * 明明刚发的新消息还在正常等待(截图:11:38 的旧消息让 11:49 的新消息背了锅)。
+     *
+     * 正确语义:**只有「最近一次用户发出、且还没收到回复」的那一轮超时才算异常**。
+     * 更早的回合不论有没有回复,都已经过去了,不该弹提示。
+     */
+    const last = turns[turns.length - 1];
+    if (!last) {
+      this._alert.syncState(DkChatPage.TIMEOUT_TITLE, false);
+      return;
+    }
+
+    /*
+     * 🔴 超时是**一次性终态**(2026-11-10 用户裁决):
+     *   「只要他超时就直接丢弃就可以了,不要对消息监听等操作,直接抛弃」
+     *
+     * 一旦这条消息被判过超时,就记进 `_handledTimeouts`,此后:
+     *   · 不再重复判定(看门狗对这个回合彻底忽略)
+     *   · 刷新页面后——本条消息仍然照常显示、可重试,但**不会再弹提示**
+     *
+     * 注意:这里处理的是「提示」这一侧。消息本身不删、不丢,
+     * 用户仍能看到自己发过什么(否则会以为消息没发出去)。
+     */
+    if (this._handledTimeouts.has(last.id)) {
+      this._alert.syncState(DkChatPage.TIMEOUT_TITLE, false);
+      return;
+    }
+
+    const userMs = last.userMessage ? Date.parse(last.userMessage.ts) || 0 : 0;
+    const hasOutput = last.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
+    const timedOut = userMs > 0 && isTurnTimedOut(Date.now() - userMs, hasOutput, last.live);
+
+    if (timedOut) {
+      // 判一次超时 → 立刻标记为已处理,之后永不再判
+      this._handledTimeouts.add(last.id);
+      this._alert.resetDismiss(DkChatPage.TIMEOUT_TITLE);
+      this._alert.syncState(
+        DkChatPage.TIMEOUT_TITLE,
+        true,
+        `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或重试。`,
+      );
+      return;
+    }
+
+    this._alert.syncState(DkChatPage.TIMEOUT_TITLE, false);
+  }
+
+  /**
+   * 已处理过(提示过)的超时回合 id。
+   *
+   * 只存内存,**不落 localStorage** —— 用户明确否决了持久化方案:
+   * 「如果我在其他地方重新登录那不是还是会弹出?」
+   * 落盘会跨账号串数据;内存态天然按登录会话隔离。
+   */
+  private readonly _handledTimeouts = new Set<string>();
+
+  /** 上一次触发超时提示的回合 id(用于判断「是不是新的一次超时」) */
+  private _lastTimeoutTurnId = '';
+
 
 
   /** 某个成果里「还在容器里、未上云盘」的首个附件(没有则 undefined) */
@@ -2387,30 +2475,67 @@ export class DkChatPage implements OnDestroy {
    *   ① 会话记录 `deliverableUrls`(落库后的权威来源,可能带成果表富信息)
    *   ② 消息流里 agent 发的文件(实时兜底,不依赖落库时间窗)
    */
+  /**
+   * 右侧栏数据(2026-11-10 改为「数据库为主」)。
+   *
+   * 用户要求:文件与成果入库,右侧栏**每次直接按会话从数据库加载**,
+   * 不再依赖「先加载聊天历史才看得到」。
+   *
+   * 三路来源,按可靠性排序:
+   *   ① `sessionFiles` —— 文件台账按 threadRoot 查出来的(权威)
+   *   ② 消息流里容器带的文件 —— 实时兜底(台账登记可能慢一拍)
+   *   ③ 会话记录的 deliverableUrls —— 旧数据兜底(新逻辑不再写入)
+   */
   readonly agentDeliverables = computed(() => {
-    const fromRecord = this.deliverables.bySessionUrls(
-      this.chat.currentSession()?.deliverableUrls,
-    );
+    const sess = this.chat.currentSession();
+    const fromDb = this._dbFiles();
     const agentFiles = this.chat
       .messages()
       .filter((m) => m.sender === 'agent' && m.file?.url)
       .map((m) => m.file!);
     const fromMessages = this.deliverables.fromAgentFiles(agentFiles);
+    const fromRecord = this.deliverables.bySessionUrls(sess?.deliverableUrls);
+    return this.deliverables.merge(fromDb, fromMessages, fromRecord);
+  });
 
-    const seen = new Set<string>();
-    const out = [];
-    for (const d of [...fromRecord, ...fromMessages]) {
-      const url = d.artifacts[0]?.url ?? d.id;
-      if (seen.has(url)) continue;
-      seen.add(url);
-      out.push(d);
+  /** 按会话从数据库加载的文件(①) */
+  private readonly _dbFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbFiles.asReadonly();
+
+  /** 拉当前会话的文件台账;会话切换时重跑 */
+  private loadSessionFiles(): void {
+    const sid = this.chat.currentSession()?.id;
+    if (!sid) {
+      this._dbFiles.set([]);
+      return;
     }
-    return out;
-  });
+    void this.deliverables
+      .loadForSession(sid)
+      .then((files) => {
+        /* 期间用户可能又切了会话,只认发请求时的那个 */
+        if (this.chat.currentSession()?.id === sid) this._dbFiles.set(files);
+      })
+      .catch(() => {
+        /* 台账加载失败不该盖掉消息流兜底的结果,静默即可 */
+        if (this.chat.currentSession()?.id === sid) this._dbFiles.set([]);
+      });
+  }
 
   constructor() {
     /*
-     * 折叠状态按当前回合集合裁剪(2026-11-10,评审 P1-2)。
+     * ① 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10)。
+     *
+     * 依赖 `messages().length` 是因为容器产出的文件要等它登记完才进台账 ——
+     * 回合结束后消息数变化,正好触发一次刷新。
+     */
+    effect(() => {
+      this.chat.currentSession()?.id;
+      this.chat.messages().length;
+      this.loadSessionFiles();
+    });
+
+    /*
+     * ② 折叠状态按当前回合集合裁剪(2026-11-10,评审 P1-2)。
      *
      * 回合 id = 用户消息 id,全局唯一;会话切换后旧回合不会再出现,
      * 但 `overrides` 会一直累加 —— `retain()` 此前**从未被任何地方调用**。
@@ -2480,6 +2605,23 @@ export class DkChatPage implements OnDestroy {
       untracked(() => this.syncClock());
     });
 
+    /*
+     * 超时未响应 → 全局提示(2026-11-10,A2 重构)。
+     *
+     * 🔴 不再依赖 `clockTick`。
+     *
+     * 原实现把判定挂在每秒时钟上,而那个**时钟在 live 变 false 时立刻停表** ——
+     * 超时恰恰发生在 live 已经变 false 之后,于是判定永远跑不到,
+     * 用户实测「超过 1 分钟动画消失,连弹窗都没有」。
+     *
+     * 现在:这里只负责**在消息变化时同步看门狗的开/关**,
+     * 真正的周期判定由 `syncTimeoutWatch()` 启动的独立 interval 完成。
+     */
+    effect(() => {
+      this.aiRows();
+      untracked(() => this.syncTimeoutWatch());
+    });
+
     /*
      * 正在输入指示器出现时贴底(2026-11-01:已移入消息流末尾)。
      *
@@ -2973,6 +3115,84 @@ export class DkChatPage implements OnDestroy {
     }
   }
 
+  /*
+   * 超时看门狗(2026-11-10,A2)。
+   *
+   * 🔴 为什么不复用 `syncClock`:
+   *    那个表在「没有任何 live 回合」时会**立即停表**,而超时恰恰发生在
+   *    `live` 已经变 false 之后(`waitingTooLong` 把 live 置 false)。
+   *    于是「时钟停 → clockTick 不变 → watchTurnTimeout 永不执行 → 提示不弹」,
+   *    用户实测「超过 1 分钟动画消失,连弹窗都没有」就是这个链路。
+   *
+   * 现在:用一个**独立**的低频看门狗(每 5 秒),只看「有没有尚未回复的等待回合」,
+   *        与 live 无关,不受 live 变 false 影响。
+   */
+  private timeoutHandle: ReturnType<typeof setInterval> | null = null;
+
+  private syncTimeoutWatch(): void {
+    const waiting = this.shouldWatchTimeout();
+    if (waiting && this.timeoutHandle === null) {
+      this.timeoutHandle = setInterval(() => {
+        this.evaluateTimeout();
+        if (!this.shouldWatchTimeout()) this.stopTimeoutWatch();
+      }, 5000);
+      this.evaluateTimeout(); // 立刻判一次,避免刚发的消息等满 5 秒
+    } else if (!waiting && this.timeoutHandle !== null) {
+      this.stopTimeoutWatch();
+    }
+  }
+
+  private stopTimeoutWatch(): void {
+    if (this.timeoutHandle !== null) {
+      clearInterval(this.timeoutHandle);
+      this.timeoutHandle = null;
+    }
+  }
+
+  /**
+   * 看门狗**存活条件**(比判定条件宽一档)。
+   *
+   * 判定发生在「满 180 秒那一刻」;如果存活条件也要求「< 180 秒」,
+   * 那到点前一刻表就停了,永远判不到。
+   * 所以存活条件放宽到「< 180 秒 + 10 秒」,留出判定与收尾的余量。
+   */
+  private shouldWatchTimeout(): boolean {
+    const now = Date.now();
+    return this.aiRows().some((r) => {
+      if (r.kind !== 'turn') return false;
+      const t = r.turn;
+      if (!t.userMessage) return false;
+      if (this._handledTimeouts.has(t.id)) return false;
+      const userMs = Date.parse(t.userMessage.ts) || 0;
+      if (!userMs) return false;
+      if (now - userMs >= TURN_TIMEOUT_MS + 10_000) return false;
+      const hasOutput = t.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
+      return !hasOutput;
+    });
+  }
+
+  /**
+   * 是否是「**刚进入超时窗口**」的回合 —— 只有这种才提示。
+   *
+   * 🔴 用户裁决:超时一次性处理,之后抛弃。
+   *    刷新页面时,那条消息早已超过 180 秒 + 10 秒的存活窗口,
+   *    `shouldWatchTimeout()` 返回 false,看门狗根本不起表 → 不会弹。
+   */
+  /**
+   * 是否存在需要看门狗盯着的回合(语义等价 `shouldWatchTimeout`)。
+   *
+   * 保留这个方法名是为了兼容既有调用点;实现统一走 `shouldWatchTimeout()`。
+   */
+  private hasPendingTurn(): boolean {
+    return this.shouldWatchTimeout();
+  }
+
+  /** 立即判一次超时(供看门狗与首次调用) */
+  private evaluateTimeout(): void {
+    const turns = this.aiRows().filter((r) => r.kind === 'turn').map((r) => r.turn);
+    this.watchTurnTimeout(turns);
+  }
+
   private stopClock(): void {
     if (this.clockHandle !== null) {
       clearInterval(this.clockHandle);

+ 44 - 19
src/app/desktop/pages/group-chat/group-chat.ts

@@ -766,34 +766,49 @@ export class DkGroupChatPage implements OnDestroy {
    *
    * 🔴 原实现按成员容器 `byAgent(id)` 拉**全量历史成果** ——
    *    成员在别的单聊里产出的文件也会出现在这个群的成果里(跨会话串味)。
-   *    单聊早已改为「按会话取」,群聊这里对齐:
+   *    两边各自修过一轮:HEAD 改为按群会话查文件台账,master 改为
+   *    会话记录 + 消息流。**合并口径:三路都取、按 url 去重**,与单聊
+   *    `chat.ts` 的 `agentDeliverables` 完全一致:
    *
-   *   ① 群会话记录上的 `deliverableUrls`(云端权威来源)
+   *   ① 文件台账按 threadRoot 查出来的(权威,容器成果入库后的落点)
    *   ② 群消息流里 agent 发的附件(实时兜底,不依赖落库时间窗)
+   *   ③ 群会话记录上的 `deliverableUrls`(旧数据兜底)
    *
    * 每条成果带上「是哪个成员产出的」,右栏据此显示归属。
    */
   protected readonly railDeliverables = computed<Deliverable[]>(() => {
-    const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
+    const fromDb = this._dbFiles();
     const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
-    const fromMessages = this.deliverablesSvc.fromAgentFiles(
-      agentMsgs.map((m) => m.file!),
-    ).map((d, i) => ({
-      ...d,
-      // 补上产出成员(`fromAgentFiles` 不带归属)
-      agentId: agentMsgs[i]?.senderId ?? '',
-    }));
+    const fromMessages = this.deliverablesSvc
+      .fromAgentFiles(agentMsgs.map((m) => m.file!))
+      .map((d, i) => ({
+        ...d,
+        // 补上产出成员(`fromAgentFiles` 不带归属)
+        agentId: agentMsgs[i]?.senderId ?? '',
+      }));
+    const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
+    return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
+  });
 
-    const seen = new Set<string>();
-    const out: Deliverable[] = [];
-    for (const d of [...fromRecord, ...fromMessages]) {
-      const url = d.artifacts[0]?.url ?? d.id;
-      if (seen.has(url)) continue;
-      seen.add(url);
-      out.push(d);
+  /** 按群会话从数据库加载的文件(①) */
+  private readonly _dbFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbFiles.asReadonly();
+
+  private loadSessionFiles(): void {
+    const sid = this.session()?.id;
+    if (!sid) {
+      this._dbFiles.set([]);
+      return;
     }
-    return out;
-  });
+    void this.deliverablesSvc
+      .loadForSession(sid)
+      .then((files) => {
+        if (this.session()?.id === sid) this._dbFiles.set(files);
+      })
+      .catch(() => {
+        if (this.session()?.id === sid) this._dbFiles.set([]);
+      });
+  }
 
   /** 成果的产出成员名(没有归属时返回空串,右栏不显示标签) */
   protected ownerNameOf(d: Deliverable): string {
@@ -931,6 +946,16 @@ export class DkGroupChatPage implements OnDestroy {
   );
 
   constructor() {
+    /*
+     * 群文件台账:会话就绪或消息数变化时重拉(2026-11-10)。
+     * 群文件按 threadRoot=群会话 id 查,不再按成员容器全量取。
+     */
+    effect(() => {
+      this.session()?.id;
+      this.messages().length;
+      this.loadSessionFiles();
+    });
+
     // 从 localStorage 恢复群内消息(幂等,重复调用无副作用)
     this.store.load();
 

+ 60 - 44
src/app/desktop/shell/desktop-shell.ts

@@ -66,20 +66,18 @@ import { NavCustomModalComponent } from '../../components/domain/nav-custom-moda
     <app-nav-custom-modal />
 
     @if (narrowBelow768()) {
-      <div class="narrow-desktop" role="alertdialog" aria-label="窗口过窄">
+      <!--
+        窗口过窄时的过渡态(2026-11-10 改为自动跳转)。
+
+        用户裁决:「不需要手动切换,自动识别比例和屏幕大小进行自动切换」。
+        所以这里不再给按钮 —— 组件已经自动导航到移动端,
+        这块只在跳转完成前一瞬显示,起「正在切换」的作用。
+      -->
+      <div class="narrow-desktop" role="status" aria-label="正在切换到手机端布局">
         <div class="narrow-card">
           <span class="narrow-icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
-          <h2>当前窗口适合手机端</h2>
-          <p>窗口宽度小于 768px。你可以切到手机端继续,或放大窗口回到桌面布局。</p>
-          <div class="narrow-actions">
-            <button type="button" class="narrow-btn ghost" (click)="dismissHint()">稍后再说</button>
-            <button type="button" class="narrow-btn" (click)="goMobile()">切换到手机端</button>
-          </div>
-          @if (device.isForced()) {
-            <button type="button" class="narrow-btn ghost" (click)="resetToAuto()">
-              恢复自动识别
-            </button>
-          }
+          <h2>正在切换到手机端布局</h2>
+          <p>当前窗口宽度小于 768px,已自动适配手机端。</p>
         </div>
       </div>
     }
@@ -266,15 +264,56 @@ export class DesktopShellComponent implements OnDestroy {
     if (this.session.token()) {
       void this.session.refreshMe((t) => this.api.fetchMe(t));
     }
-    /*
-     * 视口变化 → 跨端提示(2026-11-10 改为 DeviceService 统一判定)。
-     *
-     * 🔴 原实现是裸 `resize` 无防抖:拖拽窗口时每像素一次 set,提示反复闪。
-     *    现在走 `watchViewport()`:matchMedia 只在跨断点时触发,外加 250ms 防抖。
-     *    **不静默跳端** —— 只算出建议值,用户点确认才切。
-     */
-    this.device.watchViewport(() => this.narrowBelow768.set(this.device.suggestedMode() === 'mobile'));
-    this.narrowBelow768.set(this.device.suggestedMode() === 'mobile');
+    if (typeof window !== 'undefined') {
+      window.addEventListener('resize', () => this.onResize());
+      /*
+       * 首次进入也检查一次(2026-11-10):
+       * 用户可能直接访问 /desktop/chat,但窗口是窄的(手机上打开桌面链接)。
+       * 延迟一拍,避免在路由初始化过程中导航。
+       */
+      setTimeout(() => this.onResize(), 300);
+    }
+  }
+
+  /**
+   * 窗口尺寸变化 → 自动切换端(2026-11-10 用户裁决)。
+   *
+   * 「不需要手动切换,自动识别比例和屏幕大小进行自动切换」
+   *
+   * 🔴 两个约束:
+   *   ① **防抖 400ms**:拖动窗口时 resize 每秒触发几十次,
+   *      不防抖会疯狂导航、状态全乱(用户反馈的「切换异常」)。
+   *   ② **只在「够窄」时跳**:宽屏不主动跳回桌面端 ——
+   *      移动端外壳自己负责反向跳转,两边各管各的,不会来回打架。
+   */
+  private resizeTimer: ReturnType<typeof setTimeout> | null = null;
+
+  private onResize(): void {
+    this.narrowBelow768.set(this.isNarrow());
+    if (this.resizeTimer !== null) clearTimeout(this.resizeTimer);
+    this.resizeTimer = setTimeout(() => {
+      this.resizeTimer = null;
+      /*
+       * 🔴 判据用「**当前 URL 与目标端是否一致**」,不能用 `recheck().changed`
+       *    (2026-11-10 修正,用户截图实测)。
+       *
+       * 死结:用户直接用窄窗口打开 `/desktop/chat` 时,
+       * `DeviceService` 构造时就检测到是窄屏 → `mode` 已经是 `mobile`;
+       * 之后 `recheck()` 得到的还是 `mobile`,`changed=false` → **永远不跳**,
+       * 界面卡在「正在切换到手机端布局」不动(用户截图)。
+       *
+       * 现在:只看「我在 desktop 路由上,但屏幕该是 mobile」→ 直接跳。
+       */
+      const { mode } = this.device.recheck();
+      if (mode !== 'mobile') return;
+      void this.router.navigateByUrl('/mobile/messages', { replaceUrl: true });
+    }, 400);
+  }
+
+  /** 卸载时清掉未触发的防抖定时器,避免外壳销毁后仍导航 */
+  ngOnDestroy(): void {
+    if (this.resizeTimer !== null) clearTimeout(this.resizeTimer);
+    this.resizeTimer = null;
   }
 
   setCollapsed(collapsed: boolean): void {
@@ -294,29 +333,6 @@ export class DesktopShellComponent implements OnDestroy {
     }
   }
 
-  goMobile(): void {
-    const { path } = this.device.switchMode();
-    void this.router.navigateByUrl(path, { replaceUrl: true });
-  }
-
-  /** 用户点「稍后」→ 本次不再提示(下次跨断点仍会提示) */
-  dismissHint(): void {
-    this.device.dismissSuggestion();
-    this.narrowBelow768.set(false);
-  }
-
-  ngOnDestroy(): void {
-    this.device.stopWatching();
-  }
-
-  /** FR-10 AC-10.1:恢复自动识别 */
-  resetToAuto(): void {
-    const mode = this.device.resetToAuto();
-    void this.router.navigateByUrl(mode === 'mobile' ? '/mobile/chat-index' : '/desktop/chat', {
-      replaceUrl: true,
-    });
-  }
-
   private isNarrow(): boolean {
     return typeof window !== 'undefined' && window.innerWidth < 768;
   }

+ 19 - 0
src/app/mobile/components/m-dock/m-dock.ts

@@ -23,6 +23,7 @@ import {
   template: `
     <div class="m-dock">
       <textarea
+        #textRef
         rows="1"
         [value]="draft()"
         [placeholder]="placeholder()"
@@ -179,6 +180,9 @@ export class MDockComponent {
 
   protected readonly fileRef = viewChild.required<ElementRef<HTMLInputElement>>('fileRef');
 
+  /** 输入框(发送后要把高度收回去) */
+  private readonly textRef = viewChild<ElementRef<HTMLTextAreaElement>>('textRef');
+
   /** 文本非空 **或** 有待发附件即可发送 */
   protected readonly hasContent = computed(
     () => (this.draft() ?? '').trim().length > 0 || this.hasAttachments(),
@@ -202,6 +206,21 @@ export class MDockComponent {
   protected submit(): void {
     if (this.disabled() || this.sending() || !this.hasContent()) return;
     this.send.emit(this.draft().trim());
+    /*
+     * 发送后把输入框收回单行(2026-11-10 用户反馈)。
+     *
+     * 🔴 原实现只清内容、不收高度:多行文本发出后,`style.height` 仍是
+     *    `onInput` 撑开的值,输入框会一直保持很高 —— 实测「发文件后不恢复」。
+     *    这里显式复位;父组件清空 draft 后浏览器重新布局,不需要再量 scrollHeight。
+     */
+    this.resetHeight();
+  }
+
+  /** 把 textarea 高度收回最小值(与 CSS 的 rows=1 + min-height 对齐) */
+  private resetHeight(): void {
+    const el = this.textRef?.()?.nativeElement;
+    if (!el) return;
+    el.style.height = 'auto';
   }
 
   /** 打开系统文件选择器 */

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

@@ -24,7 +24,7 @@ import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../c
 import { ChatService } from '../../../core/chat.service';
 import { PENDING_DRAFT_KEY } from '../home/home';
 import { LoopGuardService } from '../../../core/loop-guard.service';
-import { Task } from '../../../core/api/models';
+import { Deliverable, Task } from '../../../core/api/models';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { TasksService } from '../../../core/tasks.service';
 import { fmtSize } from '../../../core/format';
@@ -40,7 +40,8 @@ import { Message } from '../../../core/api/models';
 import { resolveAgentState } from '../../../core/agent-state';
 import { mergeConsecutiveBodies } from '../../../core/message-merge';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
-import { AssistantTurn, turnsFromDm } from '../../../core/assistant-turn';
+import { AssistantTurn, isTurnTimedOut, TURN_TIMEOUT_MS, turnsFromDm } from '../../../core/assistant-turn';
+import { AlertService } from '../../../core/alert.service';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
 
 /**
@@ -941,6 +942,56 @@ export class MbChatPage implements OnDestroy {
     return out;
   });
 
+  /*
+   * 超时未响应的全局提示(2026-11-10 用户裁决,与桌面同口径)。
+   *
+   * 🔴 不自动消失,必须用户手动关闭。
+   * 🔴 不只要弹一次:**当前会话只要存在超时回合,就要一直显示**;
+   *    条件消失(收到回复)后自动撤掉,避免残留。
+   */
+  private readonly alert = inject(AlertService);
+  private static readonly TIMEOUT_TITLE = '容器长时间未响应';
+
+  private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
+    /*
+     * 🔴 只看最后一轮(2026-11-10,与桌面同口径):更早的回合不论有没有回复,
+     *    都已经过去了,不该弹提示。
+     */
+    const last = turns[turns.length - 1];
+    if (!last) {
+      this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
+      return;
+    }
+    /* 已处理过的超时 → 直接抛弃,不再判定(用户裁决 2026-11-10) */
+    if (this._handledTimeouts.has(last.id)) {
+      this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
+      return;
+    }
+    const userMs = last.userMessage ? Date.parse(last.userMessage.ts) || 0 : 0;
+    const hasOutput = last.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
+    const timedOut = userMs > 0 && isTurnTimedOut(Date.now() - userMs, hasOutput, last.live);
+
+    if (timedOut) {
+      this._handledTimeouts.add(last.id);
+      this.alert.resetDismiss(MbChatPage.TIMEOUT_TITLE);
+      this.alert.syncState(
+        MbChatPage.TIMEOUT_TITLE,
+        true,
+        `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或重试。`,
+      );
+      return;
+    }
+
+    this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
+  }
+
+  /**
+   * 已处理过(提示过)的超时回合 id。
+   *
+   * 🔴 用户裁决:超时一次性处理,之后直接抛弃。
+   *    只存内存、不落盘 —— 落盘会跨账号串数据(用户明确否决)。
+   */
+  private readonly _handledTimeouts = new Set<string>();
 
   /**
    * 把回合内**连续的 agent 正文**合成一个块(2026-11-06 用户要求)。
@@ -1026,23 +1077,37 @@ export class MbChatPage implements OnDestroy {
    * 两个来源合并去重:会话记录 `deliverableUrls` + 消息流里 agent 发的文件。
    */
   protected readonly deliverables = computed(() => {
-    const fromRecord = this.view.bySessionUrlsOf(this.chat.currentSession()?.deliverableUrls);
+    const sess = this.chat.currentSession();
+    const fromDb = this.dbFiles();
     const agentFiles = this.chat
       .messages()
       .filter((m) => m.sender === 'agent' && m.file?.url)
       .map((m) => m.file!);
     const fromMessages = this.deliverablesSvc.fromAgentFiles(agentFiles);
+    const fromRecord = this.view.bySessionUrlsOf(sess?.deliverableUrls);
+    return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
+  });
+
+  /** 按会话从数据库加载的文件(2026-11-10:与桌面端同口径) */
+  private readonly _dbFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbFiles.asReadonly();
 
-    const seen = new Set<string>();
-    const out = [];
-    for (const d of [...fromRecord, ...fromMessages]) {
-      const url = d.artifacts[0]?.url ?? d.id;
-      if (seen.has(url)) continue;
-      seen.add(url);
-      out.push(d);
+  /** 拉当前会话的文件台账;会话切换时重跑 */
+  private loadSessionFiles(): void {
+    const sid = this.chat.currentSession()?.id;
+    if (!sid) {
+      this._dbFiles.set([]);
+      return;
     }
-    return out;
-  });
+    void this.deliverablesSvc
+      .loadForSession(sid)
+      .then((files) => {
+        if (this.chat.currentSession()?.id === sid) this._dbFiles.set(files);
+      })
+      .catch(() => {
+        if (this.chat.currentSession()?.id === sid) this._dbFiles.set([]);
+      });
+  }
 
   /** 有多少成果附件还没上云(与桌面 `session-rail` 同口径) */
   protected readonly notOnCloudCount = computed(() =>
@@ -1073,6 +1138,16 @@ export class MbChatPage implements OnDestroy {
   }
 
   constructor() {
+    /*
+     * 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
+     * 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。
+     */
+    effect(() => {
+      this.chat.currentSession()?.id;
+      this.chat.messages().length;
+      this.loadSessionFiles();
+    });
+
     // 路由参数 → 打开会话;参数变化时重开
     this.route.paramMap.subscribe((pm) => {
       const agentId = pm.get('id');
@@ -1094,6 +1169,19 @@ export class MbChatPage implements OnDestroy {
       this.aiRows();
       untracked(() => this.syncClock());
     });
+
+    /*
+     * 超时未响应 → 全局提示(2026-11-10,A2 重构,与桌面同口径)。
+     *
+     * 🔴 不再依赖 `now`。
+     *    每秒时钟在「没有任何 live 回合」时停表,而超时恰恰发生在 live
+     *    已变 false 之后 → 判定永远跑不到 → 提示不弹。
+     *    现在只在这里同步看门狗开关,周期判定交给独立 interval。
+     */
+    effect(() => {
+      this.aiRows();
+      untracked(() => this.syncTimeoutWatch());
+    });
   }
 
   ngOnDestroy(): void {
@@ -1102,6 +1190,59 @@ export class MbChatPage implements OnDestroy {
       clearInterval(this.nowTimer);
       this.nowTimer = null;
     }
+    this.stopTimeoutWatch();
+  }
+
+  /*
+   * 超时看门狗(2026-11-10,A2)。
+   * 与 `syncClock` 分开:后者随 live 停表,前者只看「有无未回复的回合」。
+   */
+  private timeoutHandle: ReturnType<typeof setInterval> | null = null;
+
+  private syncTimeoutWatch(): void {
+    const waiting = this.hasPendingTurn();
+    if (waiting && this.timeoutHandle === null) {
+      this.timeoutHandle = setInterval(() => {
+        this.evaluateTimeout();
+        if (!this.hasPendingTurn()) this.stopTimeoutWatch();
+      }, 5000);
+      this.evaluateTimeout();
+    } else if (!waiting && this.timeoutHandle !== null) {
+      this.stopTimeoutWatch();
+    }
+  }
+
+  private stopTimeoutWatch(): void {
+    if (this.timeoutHandle !== null) {
+      clearInterval(this.timeoutHandle);
+      this.timeoutHandle = null;
+    }
+  }
+
+  /** 是否存在「已发出、容器还没有任何输出」的回合(与 live 无关) */
+  private hasPendingTurn(): boolean {
+    /*
+     * 🔴 2026-11-10 用户裁决:超时即终态,之后直接抛弃,不再监听。
+     *
+     * 只在「距发出还没超过超时窗口 + 10 秒」时才盯着。
+     * 刷新页面时老消息早已超出窗口 → 看门狗不会启动 → 不会再弹。
+     */
+    const now = Date.now();
+    return this.aiRows().some((r) => {
+      if (r.kind !== 'turn') return false;
+      const t = r.turn;
+      if (!t.userMessage) return false;
+      if (this._handledTimeouts.has(t.id)) return false;
+      const userMs = Date.parse(t.userMessage.ts) || 0;
+      if (!userMs) return false;
+      if (now - userMs >= TURN_TIMEOUT_MS + 10_000) return false;
+      return !t.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
+    });
+  }
+
+  private evaluateTimeout(): void {
+    const turns = this.aiRows().filter((r) => r.kind === 'turn').map((r) => r.turn);
+    this.watchTurnTimeout(turns);
   }
 
   /*

+ 47 - 16
src/app/mobile/pages/group-chat/group-chat.ts

@@ -718,23 +718,24 @@ export class MbGroupChatPage implements OnDestroy {
       .filter((f): f is NonNullable<typeof f> => !!f),
   );
 
-  /** 「成果」= 本群这次对话里成员容器产出的文件(2026-11-10,与桌面同口径) */
-  protected readonly groupDeliverables = computed(() => {
-    const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
+  /**
+   * 「成果」= 本群这次对话里成员容器产出的文件(2026-11-10,与桌面群聊同口径)。
+   *
+   * 合并口径:三路都取、按 url 去重 —— 与桌面群聊 `railDeliverables`
+   * 及单聊 `agentDeliverables` 完全一致:
+   *   ① 文件台账按群会话 id(threadRoot)查出来的(权威)
+   *   ② 群消息流里 agent 发的附件(实时兜底)
+   *   ③ 群会话记录上的 `deliverableUrls`(旧数据兜底)
+   */
+  protected readonly groupDeliverables = computed<Deliverable[]>(() => {
+    const fromDb = this._dbFiles();
     const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
-    const fromMessages = this.deliverablesSvc
-      .fromAgentFiles(agentMsgs.map((m) => m.file!))
-      .map((d) => ({ ...d, agentId: agentMsgs.find((m) => m.file?.url === d.artifacts[0]?.url)?.senderId ?? '' }));
-
-    const seen = new Set<string>();
-    const out: typeof fromRecord = [];
-    for (const d of [...fromRecord, ...fromMessages]) {
-      const url = d.artifacts[0]?.url ?? d.id;
-      if (seen.has(url)) continue;
-      seen.add(url);
-      out.push(d);
-    }
-    return out;
+    const fromMessages = this.deliverablesSvc.fromAgentFiles(agentMsgs.map((m) => m.file!)).map((d) => ({
+      ...d,
+      agentId: agentMsgs.find((m) => m.file?.url === d.artifacts[0]?.url)?.senderId ?? '',
+    }));
+    const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
+    return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
   });
 
   /** 成果产出成员名(没有归属时返回空串) */
@@ -782,6 +783,26 @@ export class MbGroupChatPage implements OnDestroy {
     turnsFromGroup(this.turns(), this.groupLive.clock(), GROUP_QUIET_MS, (id) => this.memberName(id)),
   );
 
+  /** 按群会话从数据库加载的文件(①) */
+  private readonly _dbFiles = signal<Deliverable[]>([]);
+  readonly dbFiles = this._dbFiles.asReadonly();
+
+  private loadSessionFiles(): void {
+    const sid = this.session()?.id;
+    if (!sid) {
+      this._dbFiles.set([]);
+      return;
+    }
+    void this.deliverablesSvc
+      .loadForSession(sid)
+      .then((files) => {
+        if (this.session()?.id === sid) this._dbFiles.set(files);
+      })
+      .catch(() => {
+        if (this.session()?.id === sid) this._dbFiles.set([]);
+      });
+  }
+
   /* ---------- 文件预览(2026-11-07 补齐) ---------- */
 
   /** 图片灯箱 */
@@ -852,6 +873,16 @@ export class MbGroupChatPage implements OnDestroy {
   });
 
   constructor() {
+    /*
+     * 群文件台账:会话就绪或消息数变化时重拉(2026-11-10)。
+     * 群文件按 threadRoot=群会话 id 查,不再按成员容器全量取。
+     */
+    effect(() => {
+      this.session()?.id;
+      this.messages().length;
+      this.loadSessionFiles();
+    });
+
     // 从 localStorage 恢复群内消息(幂等,重复调用无副作用)
     this.store.load();
     /*

+ 37 - 35
src/app/mobile/shell/mobile-shell.ts

@@ -60,18 +60,16 @@ interface TabDef {
     <app-prompt-modal />
 
     @if (wideViewport()) {
-      <div class="wide-mobile" role="alertdialog" aria-label="窗口适合桌面端">
+      <!--
+        窗口变宽时的过渡态(2026-11-10 改为自动跳转)。
+        用户裁决:去掉手动切换,自动识别屏幕大小切换。
+        不再给按钮,组件会自动导航到桌面端,这里只显示「正在切换」。
+      -->
+      <div class="wide-mobile" role="status" aria-label="正在切换到桌面端布局">
         <div class="wide-card">
           <span class="wide-icon" aria-hidden="true"><i class="fa-solid fa-display"></i></span>
-          <h2>当前窗口适合桌面端</h2>
-          <p>已检测到窗口宽度达到 768px,可以切换到桌面布局继续使用。</p>
-          <div class="wide-actions">
-            <button type="button" class="wide-btn ghost" (click)="dismissHint()">稍后再说</button>
-            <button type="button" class="wide-btn" (click)="goDesktop()">切换到桌面端</button>
-          </div>
-          @if (device.isForced()) {
-            <button type="button" class="wide-btn ghost" (click)="resetToAuto()">恢复自动识别</button>
-          }
+          <h2>正在切换到桌面端布局</h2>
+          <p>窗口宽度已达到 768px,已自动适配桌面端。</p>
         </div>
       </div>
     }
@@ -290,15 +288,11 @@ export class MobileShellComponent implements OnDestroy {
       void this.session.refreshMe((t) => this.api.fetchMe(t));
     }
 
-    /*
-     * 视口变化 → 跨端提示(2026-11-10 改为 DeviceService 统一判定)。
-     *
-     * 🔴 原实现是裸 `resize` 无防抖:拖拽窗口时每像素一次 set,提示反复闪。
-     *    现在走 `watchViewport()`:matchMedia 只在跨断点时触发,外加 250ms 防抖。
-     *    **不静默跳端** —— 只算出建议值,用户点确认才切。
-     */
-    this.device.watchViewport(() => this.wideViewport.set(this.device.suggestedMode() === 'desktop'));
-    this.wideViewport.set(this.device.suggestedMode() === 'desktop');
+    if (typeof window !== 'undefined') {
+      window.addEventListener('resize', () => this.onResize(), { passive: true });
+      // 首次进入也检查一次(可能直接访问了 /mobile/... 但窗口是宽的)
+      setTimeout(() => this.onResize(), 300);
+    }
 
     this.syncFromUrl(this.router.url);
     this.router.events
@@ -310,26 +304,34 @@ export class MobileShellComponent implements OnDestroy {
     void this.router.navigateByUrl(`/mobile${path}`);
   }
 
-  protected goDesktop(): void {
-    const { path } = this.device.switchMode();
-    void this.router.navigateByUrl(path, { replaceUrl: true });
-  }
+  /**
+   * 窗口尺寸变化 → 自动切换到桌面端(2026-11-10 用户裁决)。
+   *
+   * 与桌面外壳对称:防抖 400ms,避免拖窗口时反复导航。
+   * 只在「够宽」时跳,窄屏不主动跳回移动端(那边自己负责)。
+   */
+  private resizeTimer: ReturnType<typeof setTimeout> | null = null;
 
-  /** 用户点「稍后」→ 本次不再提示(下次跨断点仍会提示) */
-  protected dismissHint(): void {
-    this.device.dismissSuggestion();
-    this.wideViewport.set(false);
+  private onResize(): void {
+    this.wideViewport.set(this.isWideViewport());
+    if (this.resizeTimer !== null) clearTimeout(this.resizeTimer);
+    this.resizeTimer = setTimeout(() => {
+      this.resizeTimer = null;
+      /*
+       * 与桌面侧同一修正:判据用「目标端」,不用 `changed`。
+       * 用宽窗口直接打开 `/mobile/...` 时 `mode` 本来就是 desktop,
+       * `changed` 恒为 false → 永远不跳(用户截图实测)。
+       */
+      const { mode } = this.device.recheck();
+      if (mode !== 'desktop') return;
+      void this.router.navigateByUrl('/desktop/chat', { replaceUrl: true });
+    }, 400);
   }
 
+  /** 卸载时清掉未触发的防抖定时器,避免外壳销毁后仍导航 */
   ngOnDestroy(): void {
-    this.device.stopWatching();
-  }
-
-  protected resetToAuto(): void {
-    const mode = this.device.resetToAuto();
-    void this.router.navigateByUrl(mode === 'mobile' ? '/mobile/messages' : '/desktop/chat', {
-      replaceUrl: true,
-    });
+    if (this.resizeTimer !== null) clearTimeout(this.resizeTimer);
+    this.resizeTimer = null;
   }
 
   private isWideViewport(): boolean {