Ver Fonte

fix: 持有条自动清除 + 新建会话取消清理

## 1. 持有条在会话结束后不消失
用户实测:会话早已结束、成果都出来了,输入框上方仍挂着
「任务执行中 · 确认发送将中断当前任务」。

根因:「不点不发」改动移除了 armHeldAutoSend(空闲后自动发),
但没加替代的清理机制 —— _heldMessage 一旦写入就没人清。

修复:会话转为空闲时把持有条**自动撤回**(内容回到草稿),
而不是自动发出。若草稿已有内容则不覆盖。

## 2. 新建会话取消后左侧仍出现
用户实测:点「新建对话」→ 选容器 → 建房途中点「取消」→ 左侧出现新会话。

根因:startBlankWithRoom 是「先本地建会话 → 再建房」,用户在 await 期间
取消时会话已创建,而 close() 的 pruneEmpty(当前会话id) 会保留它。

修复:用 pendingSessionId + 哨兵标记「正在建」,取消时删除残留;
createIn 完成后二次检查标记,若已被清则删除刚建的会话。

## 验证
- tsc --noEmit 通过
- npm test 1779 SUCCESS(新增 2 条)
- npm run build 通过
- 已部署至 ai.fmode.cn(CDN 刷新任务 8188240134)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct há 18 horas atrás
pai
commit
7c44250613

+ 791 - 0
docs/03-interface/lj-test-567实现细节-回答.md

@@ -0,0 +1,791 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T00:08:09.183Z
+- 房间:`!cHe84pQ1DVG_ZXNlmkLOnaUg3YKRWvrkX6GJLafW3F0`(临时房间,可保留查阅)
+- 消息统计:正文 6 段 / 工具过程 5 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+## 请说明 5-7 三项的实现细节(我要用来对照另一台容器)
+
+**只读,贴原始代码和配置,不要总结。**
+
+---
+
+# 5. 延迟清理 + GC
+
+### 5.1 上传成功后不删源文件
+```bash
+grep -n "os.remove\|CLEANUP_LOCAL\|DEFER-CLEANUP\|delayed\|delay" /opt/data/scripts/fmode-deliverable-hook.py | head -20
+```
+请贴:
+- 上传成功后对源文件做了什么(删/留)
+- 如果留,是怎么标记的(state 字段?日志?)
+
+### 5.2 GC 脚本
+```bash
+ls -la /opt/data/scripts/ | grep -iE "gc|cleanup"
+cat /opt/data/scripts/fmode-media-gc.py 2>/dev/null | head -60
+```
+
+### 5.3 cron 配置
+```bash
+cat /opt/data/cron/jobs.json 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); [print(j.get('name'), '|', j.get('enabled'), '|', j.get('schedule') or j.get('intervalMs') or j.get('cron')) for j in (d.get('jobs') or d)]" 2>/dev/null || cat /opt/data/cron/jobs.json | head -40
+```
+
+---
+
+# 6. `fmode.kind='process'`
+
+### 6.1 发送脚本
+```bash
+ls -la /opt/data/scripts/ | grep -i "process"
+cat /opt/data/scripts/fmode-process-notice.py 2>/dev/null | head -50
+```
+
+### 6.2 谁调用它
+```bash
+grep -n "fmode-process-notice\|process-notice" /opt/data/scripts/*.py /opt/data/config.yaml 2>/dev/null | head -10
+```
+
+### 6.3 插件层面
+```bash
+ls -la /opt/data/plugins/ | grep -i "process"
+cat /opt/data/plugins/plugin-matrix-process-notice/plugin.yaml 2>/dev/null
+```
+
+**请明确回答**:`fmode.kind='process'` 是**脚本发**的还是**插件 patch 发的**?两者都在吗?
+
+---
+
+# 7. 网关防复发
+
+### 7.1 当前的 finish 脚本
+```bash
+cat /run/service/gateway-default/finish
+```
+
+### 7.2 持久化机制
+```bash
+ls -la /opt/data/patches/ | grep -i finish
+cat /opt/data/patches/fmode-gateway-finish-guard.sh 2>/dev/null | head -40
+```
+
+### 7.3 插件
+```bash
+ls -la /opt/data/plugins/ | grep -i "finish\|gateway"
+cat /opt/data/plugins/plugin-gateway-finish-guard/plugin.yaml 2>/dev/null
+grep -n "def register\|finish" /opt/data/plugins/plugin-gateway-finish-guard/__init__.py 2>/dev/null | head -15
+```
+
+**请明确回答**:finish 被改写的**触发点**是什么?插件 import 期?还是有别的机制?
+
+---
+
+## 另外,请给一份「最小可复制清单」
+
+对 5-7 每一项,告诉我:
+1. **必须的最少文件**(哪些文件是必不可少的)
+2. **必须的配置**(config.yaml / cron 里要加什么)
+3. **一句话原理**(为什么这么做)
+
+我要把这些原样转给另一台容器,让它快速对齐。
+
+---
+
+## 纪律
+
+- 只读,贴原始内容
+- 找不到的就说没有
+- 不要推断
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先把第 5~7 项的真实文件内容全部读出来,再按你的格式逐条贴原始输出。
+```
+
+### 2【prose】
+
+```text
+execute_code · from hermes_tools import terminal cmds = [ ("5.1_grep_hook",…
+```
+
+### 3【prose】
+
+```text
+我把缺的几块继续读全(hook 的 291 行附近、GC 后半段、cron、process-notice 脚本与插件、finish-guard 持久化脚本和插件)。
+```
+
+### 4【prose】
+
+```text
+第 7 部分还缺持久化脚本和插件主体,我一次性读全。
+```
+
+### 5【prose】
+
+```text
+**收到,5~7 三项已全部只读回读完成,以下是原始输出,末尾附最小可复制清单。**
+
+---
+
+# 5. 延迟清理 + GC
+
+## 5.1 上传成功后不删源文件
+
+`grep -n "os.remove\|CLEANUP_LOCAL\|DEFER-CLEANUP\|delayed\|delay" /opt/data/scripts/fmode-deliverable-hook.py` 原始输出:
+
+```
+77:CLEANUP_LOCAL = True
+753:            log("DEFER-CLEANUP keep local %s(交给 fmode-media-gc.py,1h 后清理)" % p)
+```
+
+**注意**:本文件里 **没有 `os.remove` 调用**(grep 零命中)。`CLEANUP_LOCAL = True` 只是一个常量,实际分支并不用它删文件。
+
+hook 第 747~754 行原文:
+
+```python
+        # C-2 清理:**延迟清理**。
+        # 已上云且回读 md5 一致 → 不立即删;源文件必须活到「发送时刻」,
+        # 否则回复里的 MEDIA: /media-to-send 路径在 adapter 发送前就已消失。
+        # 真正的删除交给 /opt/data/scripts/fmode-media-gc.py(cron 每 30 分钟跑一次,
+        # 只清 pushed 登记里 mtime 超过 1 小时的源文件)。
+        if same:
+            log("DEFER-CLEANUP keep local %s(交给 fmode-media-gc.py,1h 后清理)" % p)
+```
+
+**它做了哪些操作**:什么都不做(仅写一行日志 `DEFER-CLEANUP keep local ...`)。文件留着。
+
+**怎么标记的**:靠 **state JSON 的 `pushed` 数组字段**,不是日志。第 903~908 行原文:
+
+```python
+        st.setdefault("pushed", []).append({
+            "path": ..., "url": ..., "did": ...,
+            "src_mtime": int(_mtime_or_0(r["path"])), "gc": False,
+        })
+    save_state(sid, st)
+```
+
+状态文件位置(第 30、262~264 行):
+
+```python
+CACHE_DIR = HOME / "cache" / "deliverable-hook"
+def state_path(session_id: str) -> Path:
+    return CACHE_DIR / ("%s.json" % sid)
+```
+
+状态结构注释(第 14 行):
+
+```
+{"queued": [{path, tool, ts, title?, reason?}], "pushed": [{path,url,did,ts}], "seen": [...]}
+```
+
+GC 实际依赖的字段是 `path / url / src_mtime / gc / gc_ts / gc_reason`。
+
+## 5.2 GC 脚本
+
+```
+-rwxr-xr-x  1 hermes hermes   189 Nov 13 23:44 fmode-media-gc-wrapper.sh
+-rwxr-xr-x  1 hermes hermes  7592 Nov 13 23:44 fmode-media-gc.py
+```
+
+`fmode-media-gc.py` 头部原文(1~60 行):
+
+```python
+#!/usr/bin/env python3
+"""fmode-media-gc.py — 延迟清理已上云源文件(配合 fmode-deliverable-hook.py 的延迟清理策略)。
+
+背景
+----
+hook 上传成功并回读 md5 一致后**不再立即删除**源文件:回复里的 行
+和 adapter 的 media-to-send 都要在「发送时刻」还有文件可读。删早了会得到
+「文件不存在」的空附件或多发一条本地兜底。
+
+这个脚本负责真正的删除:扫描 hook 的状态目录(CACHE_DIR/*.json,字段
+`pushed: [{path,url,did,ts,src_mtime,gc}]`),对满足全部条件的源文件才删:
+
+  1. 已登记为 pushed 且带 url(= 确实上云了,本地副本不再唯一)
+  2. 源文件 mtime 距今 >= --min-age(默认 3600s = 1 小时)
+  3. 路径不在排除目录里(/opt/data/work/deliverables、/opt/data/skills 等永久保留)
+
+删完在状态里把该条 gc 置 True 并记 gc_ts,重复运行不会重复计数。
+只删登记在案的路径 —— 不做全盘 find,避免误伤。
+
+用法
+----
+  python3 fmode-media-gc.py                 # 真删
+  python3 fmode-media-gc.py --dry-run       # 只报不动
+  python3 fmode-media-gc.py --min-age 120   # 改成 2 分钟(测试用)
+  python3 fmode-media-gc.py --json          # 输出机器可读汇总
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from pathlib import Path
+
+HOME = Path("/opt/data")
+CACHE_DIR = HOME / "cache" / "deliverable-hook"
+LOG = HOME / "logs" / "media-gc.log"
+
+# 永久保留(交付目录、技能、工作区源码),GC 绝不碰
+KEEP_PREFIXES = (
+    "/opt/data/work/deliverables",
+    "/opt/data/skills",
+    "/opt/data/plugins",
+    "/opt/data/scripts",
+    "/opt/data/.fmode",
+    "/opt/data/.fmode-media-patch",
+)
+```
+
+核心删除逻辑(`gc_state` 原文节选):
+
+```python
+        if entry.get("gc") is True:
+            continue
+        out["scanned"] += 1
+
+        url = entry.get("url") or ""
+        if not url:
+            out["kept"] += 1
+            out["kept_detail"].append({"path": path, "why": "no-url(未确认上云)"})
+            continue
+
+        why = keep_by_policy(path)
+        if why:
+            out["kept"] += 1
+            out["kept_detail"].append({"path": path, "why": why})
+            continue
+
+        if not os.path.exists(path):
+            if not dry_run:
+                entry["gc"] = True
+                entry["gc_ts"] = int(now)
+                entry["gc_reason"] = "already-gone"
+                changed = True
+            continue
+
+        mt = stat_mtime(path)
+        # 取 mtime 与「上云时刻」中较晚者,防止 clock 抖动把新文件当老文件
+        basis = max(mt, float(entry.get("src_mtime") or 0))
+        age = now - basis if basis else now - float(entry.get("ts") or now)
+        if age < min_age:
+            out["kept"] += 1
+            out["kept_detail"].append({"path": path, "why": "age=%.0fs<%.0fs" % (age, min_age)})
+            continue
+        ...
+        try:
+            os.remove(path)
+        except OSError as e:
+            ...
+        entry["gc"] = True
+        entry["gc_ts"] = int(now)
+        entry["gc_reason"] = "delayed-cleanup"
+```
+
+**唯一的 `os.remove` 在这个 GC 脚本第 ~134 行,不在 hook 里。**
+
+参数默认值原文:
+
+```python
+    ap.add_argument("--min-age", type=float, default=3600.0,
+                    help="源文件至少存活多少秒才清理(默认 3600)")
+    ap.add_argument("--dry-run", action="store_true", help="只报告不删除")
+    ap.add_argument("--json", action="store_true", help="输出 JSON 汇总")
+    ap.add_argument("--quiet", action="store_true",
+                    help="无删除时完全不输出(cron watchdog 模式用;有删除才说话)")
+```
+
+wrapper 原文(全文,共 3 行有效):
+
+```bash
+#!/bin/bash
+# cron 包装:安静模式。无删除时不输出(watchdog 语义),有删除/出错才说话。
+exec python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 3600
+```
+
+## 5.3 cron 配置
+
+`/opt/data/cron/jobs.json` 里 4 个 job,逐条打印(name | enabled | script | schedule | state | last_status):
+
+```
+bigfile-notice | False | bigfile-notice.sh | {"kind": "interval", "minutes": 1, "display": "every 1m"} | completed | ok
+fmode-status-reporter | False | fmode-status-report.sh | {"kind": "cron", "expr": "*/2 * * * *", "display": "*/2 * * * *"} | paused | ok
+fmode-artifact-drain | True | fmode-artifact-drain.sh | {"kind": "cron", "expr": "*/3 * * * *", "display": "*/3 * * * *"} | scheduled | ok
+media-gc-delayed-cleanup | True | fmode-media-gc-wrapper.sh | {"kind": "interval", "minutes": 30, "display": "every 30m"} | scheduled | ok
+```
+
+**GC 对应的 job**:`media-gc-delayed-cleanup`,enabled=True,interval 30 分钟,script=`fmode-media-gc-wrapper.sh`。
+
+---
+
+# 6. `fmode.kind='process'`
+
+## 6.1 发送脚本
+
+```
+-rw-r--r--  1 hermes hermes   6837 Nov 13 21:42 fmode-process-notice.py
+```
+
+注意权限是 **644**(不是 755),靠 `python3 <path>` 调用,所以能跑。
+
+头部原文(1~26 行):
+
+```python
+#!/usr/bin/env python3
+"""fmode-process-notice.py — post_tool_call 时往 Matrix 发一条「过程消息」
+
+被 /opt/data/scripts/fmode-deliverable-hook.py 的 handle_post_tool_call 通过
+subprocess 调用(fire-and-forget),也可以单独手动跑做自检:
+
+  # 自检:真发一条到指定房间
+  python3 /opt/data/scripts/fmode-process-notice.py --room '!xxx:fmode.cn' --text '测试'
+
+设计要点(都是踩过的坑):
+  1) 必须用 subprocess 从 hook 里异步拉起 —— hook 本身是同步的,网络 IO 塞进去会拖慢工具链;
+     Hermes 对 post_tool_call 只给 20s 超时,不能堵在这里。
+  2) 房间号优先取 HERMES_SESSION_CHAT_ID(gateway 注入),否则按 session_id 查 state.db。
+  3) 发出去的消息 content 里带 fmode.kind = "process",App 右栏靠它做过程/终态分流。
+  4) 去重:同一 session 同一工具名 60s 内最多 1 条,避免连打 20 个命令刷屏。
+  5) 失败一律静默(fail-open),写日志到 /opt/data/logs/process-notice.log,绝不因为
+     通知失败影响工具链或本轮回复。
+"""
+```
+
+脚本内 `send()` 实际写入的字段(原文):
+
+```python
+        body = {
+            "msgtype": "m.text",
+            "body": text,
+            "fmode.kind": kind,
+        }
+```
+
+默认 `kind="process"`(`def send(env: dict, room: str, text: str, kind: str = "process")`)。
+
+节流:`THROTTLE_SECONDS = 60`,`TEXT_LIMIT = 60`,节流目录 `cache/process-notice`。
+
+## 6.2 谁调用它
+
+```
+/opt/data/scripts/fmode-deliverable-hook.py:291:NOTICE_SCRIPT = HOME / "scripts" / "fmode-process-notice.py"
+/opt/data/scripts/fmode-process-notice.py:2:...(自身 docstring)
+/opt/data/scripts/fmode-process-notice.py:8:  python3 /opt/data/scripts/fmode-process-notice.py --room ...
+/opt/data/config.yaml:47:    - plugin-matrix-process-notice
+/opt/data/config.yaml:64:    plugin-matrix-process-notice:
+```
+
+调用点(hook 第 291~321 行原文):
+
+```python
+NOTICE_SCRIPT = HOME / "scripts" / "fmode-process-notice.py"
+NOTICE_TOOLS = {"terminal", "write_file", "patch", "execute_code", "browser_exec"}
+
+def _spawn_process_notice(payload: dict) -> None:
+    """fire-and-forget 派生过程消息发送进程,失败绝不影响工具链。"""
+    try:
+        tool = payload.get("tool_name") or ""
+        if tool not in NOTICE_TOOLS:
+            return
+        if not NOTICE_SCRIPT.is_file():
+            return
+        env = dict(os.environ)
+        for k in ("HERMES_SESSION_CHAT_ID", "HERMES_SESSION_ID",
+                  "HERMES_SESSION_PLATFORM", "HERMES_SESSION_KEY",
+                  "MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"):
+            v = payload.get(k) or os.environ.get(k)
+            if v:
+                env[k] = str(v)
+        subprocess.Popen(
+            ["python3", str(NOTICE_SCRIPT)],
+            stdin=subprocess.PIPE,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+            env=env,
+            start_new_session=True,
+        ).communicate(input=json.dumps(payload, ensure_ascii=False).encode("utf-8"))
+    except Exception as e:
+        log("NOTICE-SPAWN-FAIL %s" % e)
+```
+
+## 6.3 插件层面
+
+```
+drwxr-xr-x  2 hermes hermes  4096 Nov 13 21:58 plugin-matrix-process-notice
+-rw-r--r--  1 hermes hermes  16656 Nov 13 21:58 __init__.py
+-rw-r--r--  1 hermes hermes   1060 Nov 13 21:58 plugin.yaml
+```
+
+`plugin.yaml` 全文:
+
+```yaml
+name: plugin-matrix-process-notice
+version: 1.3.0
+description: "给 Matrix 出站消息补 fmode.kind,只写 process、绝不写 final:patch MatrixAdapter._apply_relation_metadata(每条出站消息必经、且在 send_message_event 之前),工具进度行写 content['fmode.kind']='process',其它消息不写这个键(并清掉上游残留的 'final')。撤掉 final 的原因:_progress_metadata 对非 Discord 是恒等返回、最终回复走 _thread_metadata_for_source 字段集与 progress 同构,出口层无法区分,写错的 final 会让 App 把工具输出当正文直出。真类必须从 platform_registry 的 adapter_factory 取——按模块名 import 会拿到 plugins.* 影子实例,patch 打在空气里(v1.0 失败根因)。另挂 pre_gateway_dispatch 兜住适配器迟加载。不改 body/format/m.relates_to,异常全部吞掉不影响主流程。PATCH 落在 /opt/data 持久卷,容器重建不丢。"
+author: fmode
+kind: backend
+hooks:
+  - pre_gateway_dispatch
+provides_hooks:
+  - pre_gateway_dispatch
+```
+
+插件注入点(`__init__.py` docstring 原文):
+
+```
+  * monkey-patch MatrixAdapter._apply_relation_metadata
+    它在 send() 和 _upload_and_send() 里**每一条**出站消息都会经过,且就在构造 msg_content 之后、
+    真正 send_message_event 之前 —— 最合适的注入点。原函数先执行(线程/回复关系不变),
+    然后我们只往 msg_content 里塞一个 content["fmode.kind"],不动 body/format/m.relates_to。
+
+  分类规则(只写 process,绝不写 final):
+    - metadata["_fmode_kind"] 显式给了 process → 用
+    - metadata["_interim_send"] 为真          → "process"   (网关自己标的「非最终」)
+    - body 命中工具进度行特征(💻/🔧 之类 + 工具名)→ "process"
+    - 其余                                       → **不写 fmode.kind 这个键**
+```
+
+config.yaml 里的启用状态(原文第 43~47、64~65 行):
+
+```yaml
+plugins:
+  enabled:
+    ...
+    - plugin-matrix-process-notice
+    ...
+  entries:
+    plugin-matrix-process-notice:
+      allow_tool_override: false
+```
+
+### 明确回答
+
+**两者都在,不是二选一,但写 `fmode.kind='process'` 的是插件 patch,不是脚本。**
+
+- **脚本** `fmode-process-notice.py`:自带 `send()` 里也写 `"fmode.kind": kind`,但它发的是**一条独立的新 m.room.message 事件**(独立 event_id),文字是 `tool · <摘要>`,属于「额外再造一条过程消息」的通道。它由 hook 的 `_spawn_process_notice` 在 `post_tool_call` 里用 `subprocess.Popen` 拉起。
+- **插件** `plugin-matrix-process-notice`:patch `MatrixAdapter._apply_relation_metadata`,在网关**原生出站消息**(含 progress 气泡)上补 `content['fmode.kind']='process'`,不造新事件。
+
+两者并存 = 同一次工具调用可能既有一条「原生 progress 气泡被打上 kind」又有一条「脚本额外发的摘要行」。插件 docstring 明确解释了为什么不写 `final`(出口层无法区分终态)。
+
+---
+
+# 7. 网关防复发
+
+## 7.1 当前 finish 脚本
+
+`cat /run/service/gateway-default/finish` 全文:
+
+```sh
+#!/command/with-contenv sh
+# shellcheck shell=sh
+# FMODE-FINISH-GUARD v2 — 网关防静默瘫痪守卫
+# 持久化源: /opt/data/patches/fmode-gateway-finish-guard.sh
+# 自愈入口: /opt/data/plugins/plugin-gateway-finish-guard/
+#
+# 判据优先级:
+#   1. exit 78 (EX_CONFIG 配置错误)  → 125 永久停(重启无用)
+#   2. 存在运维显式停止标记文件       → 125 永久停(唯一人为停止通道)
+#   3. 其他一切(含 exit 0)          → 0 重启
+# 注意:绝不能把 exit 0 一律当「干净停止」,2026-11-13 的 12 分半静默瘫痪就是它造成的。
+MARKER=/opt/data/state/gateway-stopped-by-operator
+LOG=/opt/data/logs/gateway-finish-guard.log
+
+log_decision() {
+  printf '%s exit=%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "${1:-?}" "$2" >> "$LOG" 2>/dev/null
+}
+
+if [ "$1" = "78" ]; then
+  log_decision "$1" "EX_CONFIG(78) -> 125 不重启"
+  exit 125
+fi
+
+if [ -f "$MARKER" ]; then
+  log_decision "$1" "operator-stop-marker 存在 -> 125 不重启"
+  exit 125
+fi
+
+log_decision "$1" "-> 0 重启"
+exit 0
+```
+
+活文件校验:`md5sum` = `4f7e9713e8b98ef79b9a71f19eacdae4`,`grep -c FMODE-FINISH-GUARD` = **1**(守卫标记在)。
+
+日志尾部真实决策记录:
+
+```
+2026-11-13 22:21:34 exit=78 EX_CONFIG(78) -> 125 不重启
+2026-11-13 22:21:34 exit=1 -> 0 重启
+2026-11-13 23:34:02 exit=0 -> 0 重启
+2026-11-13 23:48:27 exit=1 -> 0 重启
+2026-11-14 00:41:32 exit=1 -> 0 重启
+```
+
+## 7.2 持久化机制
+
+```
+drwxr-xr-x  2 hermes hermes   4096 Nov 13 22:20 finish-backup
+-rwxr-xr-x  1 hermes hermes   8215 Nov 13 22:19 fmode-gateway-finish-guard.sh
+```
+
+`finish-backup/` 内容:
+
+```
+-rwxr-xr-x  1 hermes hermes  293 Nov  9 13:05 finish.orig-20261113-221825
+-rw-r--r--  1 hermes hermes  293 Nov 13 22:20 finish.upstream-20261113-222016
+```
+
+`fmode-gateway-finish-guard.sh` 头部原文(1~50 行):
+
+```bash
+#!/bin/bash
+# fmode-gateway-finish-guard.sh — 网关「崩了必须自启」持久化守卫
+#
+# ── 事故背景(2026-11-13 21:58)────────────────────────────────────
+#   gateway 收到 SIGTERM → 当时有 1 个活跃 turn → drain timed out → 被中断退出
+#   → 退出码 0 → 原 finish 把「0」当干净停止 → 返回 125(s6 永久放弃服务)
+#   → 容器 running 但网关死了 12 分半,无人知晓。
+#
+#   上游 service_manager.py::_render_finish_script() 的注释写明这是**有意设计**
+#   (防 #76435 的重连风暴)。但对本容器是致命错误:我们宁愿多重启几次,
+#   也不能出现「表面正常、实际瘫痪」的静默故障。
+#
+# ── 修复策略 ────────────────────────────────────────────────────
+#   新判据(优先级从上到下):
+#     1. exit 78 (EX_CONFIG 配置错误)  → 125 不重启(重启也没用,保留上游语义)
+#     2. 存在「运维显式停止」标记文件   → 125 不重启(唯一的人为停止通道)
+#     3. 其他一切情况(含 exit 0)      → 0   重启
+#
+#   ⚠ 环境变量通道不可用:finish 走 `#!/command/with-contenv sh`,
+#     with-contenv 会**清空并重建**环境,外部传入的变量在脚本内一律 UNSET
+#     (已实测)。所以人为停止只能靠标记文件,不能用 env var。
+#
+#   ⚠ 关键实测教训:`with-contenv` 是 `#!/command/execlineb -S0`,
+#     它继承**调用者的 PATH**。只有在 PATH 含 /command 时(= s6-supervise 的真实
+#     环境)`if [` 才被正确解析;在我方 shell 里手测会误报
+#     `execlineb: fatal: unable to exec ifelse` 并返回 127。
+#     验证时必须先 `export PATH="/command:$PATH"`,否则看到的是假阴性。
+#
+# ── 持久化 ──────────────────────────────────────────────────────
+#   /run/service/gateway-default/ 是 tmpfs,重建即丢;且上游
+#   container_boot._register_service() 会 rmtree 整个服务目录后重写 finish
+#   (每次容器启动 reconcile 都覆盖)。因此单改 finish 活不过一次重启。
+#   真正的持久化载体是 /opt/data/plugins/plugin-gateway-finish-guard/
+#   —— 持久卷上的 Hermes 插件,gateway 每次启动(import 时)重新打补丁。
+#   本脚本只负责「当场修复 + 验收回读」,可重复运行。
+#
+# 用法:
+#   bash /opt/data/patches/fmode-gateway-finish-guard.sh --apply   # 打补丁
+#   bash /opt/data/patches/fmode-gateway-finish-guard.sh --check   # 只读自检
+#   bash /opt/data/patches/fmode-gateway-finish-guard.sh --revert  # 还原上游行为
+set -uo pipefail
+
+SVC_DIR=/run/service/gateway-default
+FINISH="$SVC_DIR/finish"
+MARKER=/opt/data/state/gateway-stopped-by-operator
+BKDIR=/opt/data/patches/finish-backup
+LOGDIR=/opt/data/logs
+GUARD_TAG="FMODE-FINISH-GUARD"
+rc=0
+ts() { date '+%Y-%m-%d %H:%M:%S'; }
+```
+
+脚本自带三种模式(末尾原文):
+
+```bash
+case "${1:---check}" in
+  --apply)  apply_patch ;;
+  --check)  check_patch ;;
+  --revert) revert_patch ;;
+  *) echo "用法: $0 [--apply|--check|--revert]"; exit 2 ;;
+esac
+```
+
+其 `--check` 的实测判据(关键,直接抄):
+
+```bash
+  # 实测判据:必须带上 /command 到 PATH,模拟 s6-supervise 真实环境
+  got=$(PATH="/command:$PATH" "$FINISH" 0 2>/dev/null; echo $?)
+  if [ "$got" = "0" ]; then
+    echo "OK   exit=0 -> 0(会重启)"
+...
+  got=$(PATH="/command:$PATH" "$FINISH" 78 2>/dev/null; echo $?)
+  if [ "$got" = "125" ]; then
+    echo "OK   exit=78 -> 125(EX_CONFIG 不重启,保留上游语义)"
+...
+  got=$(PATH="/command:$PATH" "$FINISH" 1 2>/dev/null; echo $?)
+  if [ "$got" = "0" ]; then
+    echo "OK   exit=1 -> 0(会重启)"
+```
+
+## 7.3 插件
+
+```
+drwxr-xr-x  2 hermes hermes 4096 Nov 13 22:20 plugin-gateway-finish-guard
+-rw-r--r--  1 hermes hermes 5306 Nov 13 22:20 __init__.py
+-rw-r--r--  1 hermes hermes  285 Nov 13 22:20 plugin.yaml
+```
+
+`plugin.yaml` 全文:
+
+```yaml
+name: plugin-gateway-finish-guard
+version: 1.0.0
+description: "网关 finish 判据自愈:把「exit 0 = 干净停止 → 永不自启」改成「除 EX_CONFIG 和人为停止标记外一律重启」。持久卷载体,防容器重建后补丁丢失。"
+author: fmode
+kind: backend
+```
+
+注意:这个 `plugin.yaml` **没有 `hooks:` 段**,也没有 `provides_hooks` —— 它不靠 hook 触发。
+
+`__init__.py` 关键部分原文:
+
+```python
+def register(ctx=None):  # pragma: no cover - Hermes 可能用不同签名调用
+    install_guard()
+    return None
+
+# import 期立即执行:gateway 每次启动都会走到这里
+try:
+    _installed = install_guard()
+    if _installed:
+        log.info("[finish-guard] 启动期自愈完成")
+except Exception as _exc:  # noqa: BLE001
+    log.warning("[finish-guard] 启动期自愈异常(已忽略): %s", _exc)
+```
+ (1/2)
+```
+
+### 6【prose】
+
+```text
+```python
+FINISH_PATH = Path("/run/service/gateway-default/finish")
+GUARD_LOG = Path("/opt/data/logs/gateway-finish-guard.log")
+
+def install_guard(force: bool = False) -> bool:
+    """把 v2 判据写入 finish。幂等:已是 v2 则跳过。返回是否处于守卫状态。"""
+    try:
+        if not FINISH_PATH.parent.is_dir():
+            log.debug("[finish-guard] 服务目录不存在,跳过: %s", FINISH_PATH.parent)
+            return False
+        current = ""
+        try:
+            current = FINISH_PATH.read_text(encoding="utf-8", errors="replace")
+        except OSError:
+            pass
+        if GUARD_TAG in current and not force:
+            log.debug("[finish-guard] finish 已是 v2,无需重写")
+            return True
+        if current and GUARD_TAG not in current:
+            bdir = Path("/opt/data/patches/finish-backup")
+            bdir.mkdir(parents=True, exist_ok=True)
+            import time
+            bak = bdir / f"finish.upstream-{time.strftime('%Y%m%d-%H%M%S')}"
+            ...
+        FINISH_PATH.write_text(NEW_FINISH, encoding="utf-8")
+        os.chmod(FINISH_PATH, 0o755)
+        log.info("[finish-guard] finish 已修回 v2 判据(exit 0 也会重启)")
+        return True
+    except Exception as exc:  # 绝不因自愈失败阻断网关启动
+        log.warning("[finish-guard] 打补丁失败,网关继续启动: %s", exc)
+        return False
+```
+
+模块 docstring 第 10~12 行原文:
+
+```
+`/opt/data` 是持久卷,Hermes 在 gateway 启动时 import 本插件。
+所以把「重新打补丁」的动作放在 import 期执行:只要插件还在、还在 config.yaml
+的 plugins.enabled 里,每次网关启动都会自动把 finish 修回 v2 判据。
+```
+
+### 明确回答
+
+**触发点是「插件 import 期」+ `register()`,不是 hook,也没有别的机制。**
+
+具体两处,都在 `__init__.py`:
+1. **模块顶层(import 期立即执行)**:`try: _installed = install_guard()` —— 这是真正起作用的时机,gateway 每次启动 import 插件就修一次。
+2. **`register(ctx=None)`**:同样调 `install_guard()`,作为插件系统正常加载路径的兜底。
+
+没有 `pre_gateway_dispatch` 之类的 hook,`plugin.yaml` 里无 `hooks:` 键。另外 `patches/fmode-gateway-finish-guard.sh` 是**手动/当场修复工具**(`--apply/--check/--revert`),不是自动化触发点。
+
+---
+
+# 最小可复制清单
+
+## 5. 延迟清理 + GC
+
+**1. 必须的最少文件**
+- `/opt/data/scripts/fmode-deliverable-hook.py` —— 其中只需含「上传成功后不删、只 log `DEFER-CLEANUP`」那段 + `pushed` 登记里写 `src_mtime` 和 `gc: False`
+- `/opt/data/scripts/fmode-media-gc.py`(唯一真正 `os.remove` 的地方)
+- `/opt/data/scripts/fmode-media-gc-wrapper.sh`(3 行,`--quiet --min-age 3600`)
+
+**2. 必须的配置**
+- `config.yaml` 的 `hooks.post_tool_call`(matcher `write_file|patch|execute_code|terminal`,`python3 /opt/data/scripts/fmode-deliverable-hook.py`,timeout 20)与 `hooks.transform_llm_output`(同脚本,timeout 180)
+- cron 一个 job:`script: fmode-media-gc-wrapper.sh`,`schedule: {"kind":"interval","minutes":30}`,`enabled: true`
+- 状态目录 `/opt/data/cache/deliverable-hook/*.json` 需可写(GC 读它,GC 自己 `mkdir`)
+
+**3. 一句话原理**
+回复里的 `` 行和 adapter 的 media-to-send 在「发送时刻」还要读文件,所以上传成功后不能立即删;改为把 `path/url/src_mtime/gc` 登记进 state JSON,由 cron 每 30 分钟扫一遍,只删「有 url + 不在 KEEP_PREFIXES + mtime 超过 1 小时 + 未 gc」的文件,删后置 `gc:True` 保证幂等。
+
+## 6. `fmode.kind='process'`
+
+**1. 必须的最少文件**
+- `/opt/data/plugins/plugin-matrix-process-notice/`(`__init__.py` + `plugin.yaml`)——**这是真正给原生出站消息打 `fmode.kind=process` 的**
+- `/opt/data/scripts/fmode-process-notice.py` —— 可选,脚本通道(额外发独立摘要 event)
+- `/opt/data/scripts/fmode-deliverable-hook.py` 里 `_spawn_process_notice()`(若保留脚本通道)
+
+**2. 必须的配置**
+- `config.yaml`:`plugins.enabled` 加 `plugin-matrix-process-notice`;`plugins.entries.plugin-matrix-process-notice.allow_tool_override: false`
+- 脚本通道需要 `/opt/data/.env` 里 `MATRIX_HOMESERVER / MATRIX_ACCESS_TOKEN / MATRIX_USER_ID`(脚本 `read_env()` 读它,进程环境优先)
+
+**3. 一句话原理**
+patch `MatrixAdapter._apply_relation_metadata`(每条出站消息必经、且在 `send_message_event` 之前),只对工具进度行写 `content['fmode.kind']='process'`、其余不写这个键并清掉上游残留的 `final`——因为出口层无法可靠区分终态,写错的 `final` 会让 App 把工具输出当正文。**必须从 `platform_registry` 的 `adapter_factory` 取真类**,按模块名 import 会拿到影子实例、patch 打在空气里。
+
+## 7. 网关防复发
+
+**1. 必须的最少文件**
+- `/opt/data/plugins/plugin-gateway-finish-guard/`(`__init__.py` 内嵌 `NEW_FINISH` 全文 + `install_guard()` + import 期执行;`plugin.yaml`)
+- `/opt/data/patches/fmode-gateway-finish-guard.sh`(手动 `--apply/--check/--revert` 工具,建议一起带上)
+- `/opt/data/patches/finish-backup/`(备份目录,插件会自动写 `finish.upstream-*`)
+- 写权限前提:`/opt/data/state/`(放 operator stop 标记)、`/opt/data/logs/`
+
+**2. 必须的配置**
+- `config.yaml`:`plugins.enabled` 加 `plugin-gateway-finish-guard`;`plugins.entries.plugin-gateway-finish-guard.allow_tool_override: false`
+- `plugin.yaml` **不需要** `hooks:` 段(触发靠 import 期)
+- 停机唯一合法方式:`touch /opt/data/state/gateway-stopped-by-operator`
+
+**3. 一句话原理**
+`/run/service/*/finish` 是 tmpfs,且上游 `container_boot._register_service()` 每次启动 rmtree 重写,单改活不过重启;所以把补丁正文内嵌进 `/opt/data` 持久卷上的插件,在 **import 期**重写 finish 为「78 → 125、有 operator 标记 → 125、其余(含 exit 0)→ 0 重启」,杜绝「exit 0 当干净停止 → s6 永久放弃服务」的静默瘫痪。验收必须 `PATH="/command:$PATH" finish <code>`,否则 `with-contenv` 会假阴性。
+
+---
+
+**找不到/没有的**:`fmode-deliverable-hook.py` 里没有任何 `os.remove`;`plugin-gateway-finish-guard/plugin.yaml` 里没有 `hooks` 段。 (2/2)
+```
+
+### 7【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `🐍 Running code from hermes_tools import terminal cmd...`
+- `🐍 Running code from hermes_tools import terminal cmd...`
+- `🐍 Running code from hermes_tools import terminal cmd...`
+- `* 🐍 Running code from hermes_tools import terminal cmd... (×2)`
+- `🐍 Running code from hermes_tools import terminal cmd...`

+ 565 - 0
docs/03-interface/lj-test登记触发机制-回答.md

@@ -0,0 +1,565 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T00:04:46.737Z
+- 房间:`!IVKEf_jTQ43W7Uz_iMqNkCbhCd0nLr7MDKi28DaiAwc`(临时房间,可保留查阅)
+- 消息统计:正文 27 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+## 请精确说明:成果登记是在**哪一行代码**触发的?
+
+我需要一份可对照的实现细节,用来排查另一台容器登记不生效的问题。
+
+**只读,贴原始代码,不要总结。**
+
+### 1. 登记调用在哪
+
+```bash
+grep -n "registerFile\|THXAOGKiPK\|_hub_register\|hub" /opt/data/scripts/fmode-deliverable-hook.py | head -20
+```
+
+请贴出:
+- 函数名 + 行号
+- 调用链:从哪个 hook 进来 → 经过哪些函数 → 到 `registerFile`
+
+### 2. 登记的触发时机
+
+```bash
+grep -n "def handle_post_tool_call\|def _on_transform_llm_output\|def upload_and_register" -A30 /opt/data/scripts/fmode-deliverable-hook.py | head -80
+```
+
+回答:
+- 是 **post_tool_call** 时登记?还是 **transform_llm_output** 时?
+- 还是上传成功后立刻登记?
+
+### 3. roomId 从哪来
+
+```bash
+grep -n "roomId\|chat_id\|_chat_id_of\|HERMES_SESSION_CHAT_ID" /opt/data/scripts/fmode-deliverable-hook.py | head -20
+```
+
+回答:真实发送时,roomId 是怎么拿到的?
+
+### 4. 有没有依赖 `pushed[]` 状态
+
+```bash
+grep -n "pushed" /opt/data/scripts/fmode-deliverable-hook.py | head -20
+```
+
+回答:登记是否依赖某个 state 文件里的 `pushed` 数组?
+
+---
+
+## 我要对比的差异
+
+另一台容器 `agent-node-kangshifu` 的现象:
+- 文件能发出去(`m.file` 的 url 正确是 s3 直链)
+- 但 `listByUrl` 查不到台账(count=0)
+- 它在**探测脚本里**调用能登记成功,但**回复正文的 MEDIA 真实投递**不登记
+
+**我想知道:你是靠什么机制保证「真实投递路径也会登记」的?**
+
+贴代码,不要推断。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先读取这个 hook 脚本的原文,再逐项给你贴出代码。
+```
+
+### 2【prose】
+
+```text
+terminal · ls -la /opt/data/scripts/ | head -40; echo "---"; wc -l /opt…
+```
+
+### 3【prose】
+
+```text
+```
+ls -la /opt/data/plugins/ 2>/dev/null...
+```
+```
+cd /opt/data/scripts && grep -n "regi...
+```
+```
+
+### 4【prose】
+
+```text
+* ```
+ls -la /opt/data/plugins/ 2>/dev/null...
+```
+```
+cd /opt/data/scripts && grep -n "regi...
+```
+```
+cd /opt/data/scripts && grep -n "^def...
+```
+📖 Reading fmode-deliverable-hook.py L324-513
+```
+
+### 5【prose】
+
+```text
+* ```
+ls -la /opt/data/plugins/ 2>/dev/null...
+```
+```
+cd /opt/data/scripts && grep -n "regi...
+```
+```
+cd /opt/data/scripts && grep -n "^def...
+```
+📖 Reading fmode-deliverable-hook.py L324-513
+📖 Reading fmode-deliverable-hook.py L513-757
+```
+
+### 6【prose】
+
+```text
+* ```
+ls -la /opt/data/plugins/ 2>/dev/null...
+```
+```
+cd /opt/data/scripts && grep -n "regi...
+```
+```
+cd /opt/data/scripts && grep -n "^def...
+```
+📖 Reading fmode-deliverable-hook.py L324-513
+📖 Reading fmode-deliverable-hook.py L513-757
+📖 Reading fmode-deliverable-hook.py L845-994
+```
+
+### 7【prose】
+
+```text
+现在看配置:hook 是怎么挂的、session_id 和 chat_id 从哪注入,这是排查 kangshifu 问题的关键。
+```
+
+### 8【prose】
+
+```text
+* ```
+ls -la /opt/data/plugins/ 2>/dev/null...
+```
+```
+cd /opt/data/scripts && grep -n "regi...
+```
+```
+cd /opt/data/scripts && grep -n "^def...
+```
+📖 Reading fmode-deliverable-hook.py L324-513
+📖 Reading fmode-deliverable-hook.py L513-757
+📖 Reading fmode-deliverable-hook.py L845-994
+💻 terminal
+```
+cd /opt/data/scripts && grep -n "push...
+```
+```
+cd /opt/data && grep -n "hook" -A25 c...
+```
+```
+
+### 9【prose】
+
+```text
+```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+
+### 10【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+
+### 11【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+cd /opt/hermes && grep -rn "HERMES_SE...
+```
+```
+
+### 12【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+cd /opt/hermes && grep -rn "HERMES_SE...
+```
+```
+cd /opt/hermes && grep -n "def run_ho...
+```
+📖 Reading session_context.py L30-99
+```
+
+### 13【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+cd /opt/hermes && grep -rn "HERMES_SE...
+```
+```
+cd /opt/hermes && grep -n "def run_ho...
+```
+📖 Reading session_context.py L30-99
+💻 terminal
+```
+cd /opt/hermes && grep -n "def build_...
+```
+```
+cd /opt/hermes && grep -rn "def build...
+```
+```
+
+### 14【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+cd /opt/hermes && grep -rn "HERMES_SE...
+```
+```
+cd /opt/hermes && grep -n "def run_ho...
+```
+📖 Reading session_context.py L30-99
+💻 terminal
+```
+cd /opt/hermes && grep -n "def build_...
+```
+```
+cd /opt/hermes && grep -rn "def build...
+```
+📖 Reading local.py L332-391
+```
+
+### 15【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+cd /opt/hermes && grep -rn "HERMES_SE...
+```
+```
+cd /opt/hermes && grep -n "def run_ho...
+```
+📖 Reading session_context.py L30-99
+💻 terminal
+```
+cd /opt/hermes && grep -n "def build_...
+```
+```
+cd /opt/hermes && grep -rn "def build...
+```
+📖 Reading local.py L332-391
+💻 terminal
+```
+cd /opt/hermes && grep -n "_sanitize_...
+```
+```
+
+### 16【prose】
+
+```text
+接下来要确认三点:hook 子进程实际拿到的环境变量是否包含 `HERMES_SESSION_CHAT_ID`,以及 `_resolve_agent_id` 在本机返回什么。
+```
+
+### 17【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "hooks" -A40 ...
+```
+```
+cd /opt/data && grep -rn "HERMES_SESS...
+```
+```
+cd /opt/data/plugins/plugin-deliverab...
+```
+```
+cd /opt/hermes && grep -rn "HERMES_SE...
+```
+```
+cd /opt/hermes && grep -n "def run_ho...
+```
+📖 Reading session_context.py L30-99
+💻 terminal
+```
+cd /opt/hermes && grep -n "def build_...
+```
+```
+cd /opt/hermes && grep -rn "def build...
+```
+📖 Reading local.py L332-391
+💻 terminal
+```
+cd /opt/hermes && grep -n "_sanitize_...
+```
+```
+cd /opt/hermes && grep -n "def _sanit...
+```
+```
+cd /opt/hermes && grep -n "class Shel...
+```
+```
+
+### 18【prose】
+
+```text
+```
+echo "--- agent_id ---"; cat /run/s6/...
+```
+```
+cd /opt/hermes && sed -n '250,330p' a...
+```
+```
+
+### 19【prose】
+
+```text
+关键发现:容器里**没有** `FMODE_AGENT_ID`,也没有 `AGENT_ID`。我实测确认 `_resolve_agent_id()` 在本机返回什么。
+```
+
+### 20【prose】
+
+```text
+* ```
+echo "--- agent_id ---"; cat /run/s6/...
+```
+```
+cd /opt/hermes && sed -n '250,330p' a...
+```
+```
+grep -rn "FMODE_AGENT_ID\|AGENT_ID" /...
+```
+```
+cd /opt/data/scripts && python3 -c " ...
+```
+```
+
+### 21【prose】
+
+```text
+用真实日志验证登记确实在 transform 阶段发生(并确认 agentId 落的是什么)。
+```
+
+### 22【prose】
+
+```text
+```
+cd /opt/data && python3 -c " ...
+```
+```
+cd /opt/data && ls -la logs/ 2>/dev/n...
+```
+```
+
+### 23【prose】
+
+```text
+```
+cd /opt/data && grep -n "LOG\s*=\|LED...
+```
+```
+
+### 24【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "LOG\s*=\|LED...
+```
+```
+cd /opt/data/cache/deliverable-hook &...
+```
+```
+
+### 25【prose】
+
+```text
+* ```
+cd /opt/data && grep -n "LOG\s*=\|LED...
+```
+```
+cd /opt/data/cache/deliverable-hook &...
+```
+🐍 Running code from hermes_tools import terminal # 真...
+```
+
+### 26【prose】
+
+```text
+execute_code · from hermes_tools import terminal # 真实历史记录:HUB / REPORT / PU…
+```
+
+### 27【prose】
+
+```text
+我不改动代码,只按原文贴出结果。以下每条都附有真实回读的证据。
+
+## 1. 登记调用在哪(原文)
+
+```
+434:HUB_FN = "THXAOGKiPK"  # fmode-file-hub:带会话归属的成果登记
+465:def _hub_register(path: str, url: str, thread_root: str, room_id: str) -> dict:
+478:        "action": "registerFile",
+738:                hub = _hub_register(p, url, thread_root, room_id)
+745:            log("HUB %s ok=%s detail=%s id=%s" % ...)
+```
+
+调用链(行号都是真的):
+
+```
+config.yaml:79  transform_llm_output → python3 /opt/data/scripts/fmode-deliverable-hook.py
+main() 969-970        → handle_transform_llm_output(payload)
+handle_transform_llm_output 882  → upload_and_register(todo, title, summary)
+upload_and_register 738          → _hub_register(p, url, thread_root, room_id)
+_hub_register 477-489            → _hub_call({... "action":"registerFile" ...})
+_hub_call 445-452                → POST   id=THXAOGKiPK
+```
+
+`post_tool_call` 这条链路里**完全没有**登记代码,它只负责把文件放进队列:
+
+```
+967-968        if event == "post_tool_call": return handle_post_tool_call(payload)
+352            st.setdefault("queued", []).append({...})
+360-361        if changed: save_state(sid, st)
+```
+
+## 2. 触发时机
+
+**在 transform_llm_output 阶段,而且是「上传成功 + 回读 md5 一致」之后立刻登记**,不在 post_tool_call 里。
+
+```
+731            res.append({"path": p, "ok": same, "url": url, ...})
+734            # ⑤ 新云函数 fmode-file-hub 登记:带 threadRoot 会话归属
+736            if same and url:
+737                if thread_root:
+738                    hub = _hub_register(p, url, thread_root, room_id)
+739                else:
+740                    hub = {"ok": False, "skipped": True, "detail": "no-threadRoot"}
+```
+
+## 3. roomId 从哪来
+
+```
+593        room_id = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+```
+
+`HERMES_SESSION_CHAT_ID` 由 Hermes 在 hook 子进程的环境变量里注入(`/opt/hermes/agent/shell_hooks.py:314` 这一行是 `env=build_subprocess_env(...)`,它继承的是父环境的快照)。我当前进程实测:
+
+```
+HERMES_SESSION_CHAT_ID = '!IVKEf_jTQ43W7Uz_iMqNkCbhCd0nLr7MDKi28DaiAwc'
+```
+
+`threadRoot` 是另一条路径,走 state.db(378-427),三级回落:`sessions.thread_id` → `sessions.chat_id` / session_key 里的 `!room` 段 → 返回 None。实测本会话:
+
+```
+_thread_root() = '!IVKEf_jTQ43W7Uz_iMqNkCbhCd0nLr7MDKi28DaiAwc'
+hook.log: THREADROOT-FALLBACK sid=20261114_080335_380587d6 -> room=!IVKEf_...
+```
+
+## 4. 是否依赖 pushed[]
+
+**登记(_hub_register)不读 pushed。** pushed 只在 transform hook 里用于两件事:
+
+```
+853        allowed_urls = {p.get("url") for p in st.get("pushed", []) ...}   # 正文 URL 白名单
+862-863    pushed_paths = ...; todo = [q for q in queued if q["path"] not in pushed_paths]  # 避免重复上传
+903-906    st.setdefault("pushed", []).append({...})   # 上传后才写
+```
+
+真正负责登记的门槛只有两个:**thread_root 非空** + **回读 md5 一致**。
+
+## 回答「靠什么保证真实投递路径也登记」
+
+机制只有一句话:**登记不挂在发送动作上,而是挂在「上传成功并回读校验通过」这一行(738)。** 回复正文、MEDIA 投递、adapter 怎么发,全都和登记无关 —— transform hook 跑完时台账已经写完了,之后模型正文里有没有 都不影响。
+
+## 你那台 count=0 的三个真实成因(本机日志里的原样报错)
+
+```
+HUB ... ok=False detail=no-threadRoot id=None
+HUB ... ok=False detail=hub-failed:{"code": 400, "msg": "roomId required"}
+```
+
+1. **`no-threadRoot`**:hook 子进程里 `HERMES_SESSION_ID` 为空 → `_thread_root()` 返回 None → 直接跳过登记,文件照常上云。日志原文:`NO-THREADROOT sid=None(照常上传,跳过登记)`。**探测脚本里能成功,是因为你手动传了 session/chat_id;而真实投递时 hook 子进程环境里没有这个变量。**
+2. **`roomId required`**:`thread_root` 拿到了(走了 session_key 回落),但 `os.environ.get("HERMES_SESSION_CHAT_ID")` 是空串 → 云函数侧校验拒绝。这条最像 kangshifu 的现象:url 对、文件发得出去、台账 0 条。
+3. **agentId 兜底写死**:`_resolve_agent_id()` 本机实测返回 `'agent-node-lj-test'`,因为容器里**根本没有** `FMODE_AGENT_ID` 和 `AGENT_ID`(`/run/s6/container_environment/` 里只有 FMODE_API_BASE_URL / FMODE_API_KEY / FMODE_SESSION_TOKEN)。如果 kangshifu 上登记用的 agentId 与 listByUrl 查询用的不一致,台账按 agentId 过滤也会查到 0 条。
+
+对比 kangshifu 时先看它的 `hook.log` 里 `HUB` 那一行的 `detail=`,就能直接判定落在上面哪一类。
+
+*(说明:本次没有写任何新成果文件,所以不会产生新的上云条目。)*
+```
+
+### 28【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls -la /opt/data/scripts/ | head -40;... ````

+ 107 - 0
docs/03-interface/指令-kangshifu-567对齐清单.md

@@ -0,0 +1,107 @@
+## 5-7 三项:lj-test 的完整实现(照这个做)
+
+我拿到了 lj-test 的实际实现。**以下是它的原话和文件清单,照着做即可。**
+
+---
+
+# 5. 延迟清理 + GC
+
+### 必须的最少文件
+| 文件 | 作用 |
+|---|---|
+| `/opt/data/scripts/fmode-deliverable-hook.py` | 上传成功后**不删**,只 log `DEFER-CLEANUP`;`pushed` 登记里写 `src_mtime` 和 `gc: False` |
+| `/opt/data/scripts/fmode-media-gc.py` | **唯一真正 `os.remove` 的地方** |
+| `/opt/data/scripts/fmode-media-gc-wrapper.sh` | 3 行,`--quiet --min-age 3600` |
+
+### 必须的配置
+- `config.yaml`:
+  - `hooks.post_tool_call`(matcher `write_file|patch|execute_code|terminal`,`python3 /opt/data/scripts/fmode-deliverable-hook.py`,timeout 20)
+  - `hooks.transform_llm_output`(同脚本,timeout 180)
+- cron 一个 job:`script: fmode-media-gc-wrapper.sh`,`schedule: {"kind":"interval","minutes":30}`,`enabled: true`
+- 状态目录 `/opt/data/cache/deliverable-hook/*.json` 需可写
+
+### 一句话原理(lj-test 原话)
+> 回复里的 `MEDIA:` 行和 adapter 的 media-to-send 在「发送时刻」还要读文件,所以上传成功后**不能立即删**;
+> 改为把 `path/url/src_mtime/gc` 登记进 state JSON,由 cron 每 30 分钟扫一遍,
+> 只删「有 url + 不在 KEEP_PREFIXES + mtime 超过 1 小时 + 未 gc」的文件,删后置 `gc:True` 保证幂等。
+
+---
+
+# 6. `fmode.kind='process'`
+
+### 必须的最少文件
+| 文件 | 作用 |
+|---|---|
+| `/opt/data/plugins/plugin-matrix-process-notice/`(`__init__.py` + `plugin.yaml`) | **真正给原生出站消息打 `fmode.kind=process` 的** |
+| `/opt/data/scripts/fmode-process-notice.py` | 可选,脚本通道 |
+| `fmode-deliverable-hook.py` 里 `_spawn_process_notice()` | 若保留脚本通道 |
+
+### 必须的配置
+- `config.yaml`:`plugins.enabled` 加 `plugin-matrix-process-notice`;`plugins.entries.plugin-matrix-process-notice.allow_tool_override: false`
+- 脚本通道需要 `/opt/data/.env` 里 `MATRIX_HOMESERVER / MATRIX_ACCESS_TOKEN / MATRIX_USER_ID`
+
+### 一句话原理(lj-test 原话)
+> patch `MatrixAdapter._apply_relation_metadata`(每条出站消息必经、且在 `send_message_event` 之前),
+> 只对工具进度行写 `content['fmode.kind']='process'`、其余不写这个键并清掉上游残留的 `final`
+> ——因为出口层无法可靠区分终态,写错的 `final` 会让 App 把工具输出当正文。
+> **必须从 `platform_registry` 的 `adapter_factory` 取真类**,按模块名 import 会拿到影子实例、patch 打在空气里。
+
+---
+
+# 7. 网关防复发
+
+### 必须的最少文件
+| 文件 | 作用 |
+|---|---|
+| `/opt/data/plugins/plugin-gateway-finish-guard/`(`__init__.py` 内嵌 `NEW_FINISH` 全文 + `install_guard()` + **import 期执行**;`plugin.yaml`) | 核心 |
+| `/opt/data/patches/fmode-gateway-finish-guard.sh` | 手动 `--apply/--check/--revert` 工具 |
+| `/opt/data/patches/finish-backup/` | 备份目录 |
+
+### 必须的配置
+- `config.yaml`:`plugins.enabled` 加 `plugin-gateway-finish-guard`;`allow_tool_override: false`
+- **`plugin.yaml` 不需要 `hooks:` 段**(触发靠 import 期)
+- 停机唯一合法方式:`touch /opt/data/state/gateway-stopped-by-operator`
+
+### 一句话原理(lj-test 原话)
+> `/run/service/*/finish` 是 **tmpfs**,且上游 `container_boot._register_service()` **每次启动 rmtree 重写**,单改活不过重启;
+> 所以把补丁正文内嵌进 `/opt/data` 持久卷上的插件,在 **import 期**重写 finish 为
+> 「78 → 125、有 operator 标记 → 125、其余(含 exit 0)→ 0 重启」,
+> 杜绝「exit 0 当干净停止 → s6 永久放弃服务」的静默瘫痪。
+> **验收必须 `PATH="/command:$PATH" finish <code>`**,否则 `with-contenv` 会假阴性。
+
+---
+
+# 你要做的
+
+按顺序做,每项做完报一次:
+
+| 序 | 任务 | 关键点 |
+|---|---|---|
+| 1 | **先完成第 4 项(台账)的真实验收** | 上一轮你说改到 hook 层了,但我查 `listByUrl` 还是查不到。先把这个做实 |
+| 2 | 延迟清理 + GC | 照上面 5 的清单 |
+| 3 | `fmode.kind` | 照上面 6 的清单 |
+| 4 | 网关防复发 | 照上面 7 的清单 |
+
+---
+
+# 纪律
+
+- **不要用探测脚本验证**——必须走真实 MEDIA 投递
+- 验证要贴 **listByUrl 原始返回**
+- 每项做完单独报,不要一次改一堆
+- 做不到就说卡在哪
+
+---
+
+## 最后确认第 4 项
+
+你说:
+> 「hook 层登记成功!台账已登记 id=vRV4hRtsUv dup=False room=!6CCVcmQs-...」
+> 「listByUrl count: 1,全部字段正确」
+
+**但我从 App 侧查最近的 s3 文件,`listByUrl` 还是 0。**
+
+请做一次**真实 MEDIA 投递**(在回复正文写 `MEDIA:/opt/data/xxx.txt`),
+然后**贴出那次投递对应的 s3 url 的 listByUrl 原始返回**。
+
+如果 count 还是 0,说明 hook 层也没真正跑到。

+ 110 - 0
docs/03-interface/指令-kangshifu-台账未生效.md

@@ -0,0 +1,110 @@
+## 台账登记没生效:真实路径下 listByUrl 仍是 0
+
+你说「台账登记成功 id=5fxeMR18g2」,但**我从 App 侧用你的文件 URL 查,两条都是 count=0**:
+
+```
+📎 ledgercheck-1794613671.txt
+  url: https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/IXlPHAtMsw/0-mwfmony0gx-ledgercheck-1794613671.txt
+  台账: ❌ count=0
+
+📎 ledgercheck-1794613671.txt
+  url: https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/2GfBl2YIFR/0-mwfmoi7ot6-ledgercheck-1794613671.txt
+  台账: ❌ count=0
+```
+
+---
+
+## 我判断的问题
+
+你自己也说过:
+
+> 「真实发送成功了(`$w8q8U4GhslTtM3nRD_h0wPRUNbtj4RL_gLaaMwn_bCk`),但**没有出现任何台账登记日志**。
+> 这证明补丁虽然生效了,但登记代码没有执行。」
+
+**然后你改用了 `_probe_send.py` 这个探测脚本去验证**——脚本里能登记成功,但那不是 App 的真实路径。
+
+**两条路径走的是不同的代码分支**:
+
+| 路径 | 是否登记 |
+|---|---|
+| `_probe_send.py` 里手动调 `send_document` | ✅ 登记了 |
+| **回复正文里的 `MEDIA:` 走真实投递** | ❌ **没登记** |
+
+---
+
+## 要修的:把登记接入**真实投递路径**
+
+真实投递链是:
+
+```
+你回复正文写 MEDIA:
+  → plugin-deliverable-inject 保留 MEDIA 行
+  → _deliver_media_from_response(response, ...) 提取 MEDIA
+  → 内部调 send_document / _send_local_file / _upload_and_send
+  → 这里才是真正上传的地方
+```
+
+**登记要挂在这条链里**,而不是只在探测脚本里。
+
+### 具体建议
+
+在 `_upload_and_send`(或 `_send_local_file`)**上传成功、拿到 s3 URL 之后**,立刻:
+
+```python
+# 上传拿到 s3 url 后
+try:
+    _register_to_hub(room_id, s3_url, key, filename, mime, size)
+except Exception as e:
+    log("REGISTER-FAIL %s: %s" % (s3_url, e))
+```
+
+关键:**从真实的 `room_id` 取**,不要用脚本里硬编码的。
+
+---
+
+## 请这样做验证(不要用探测脚本)
+
+1. 在**你和我对话的这个房间**的回复正文里,写一行 `MEDIA:/opt/data/xxx.txt`(真实存在的文件)
+2. 让网关自己投递
+3. 然后你自己查一次:
+   ```bash
+   # 用你刚发出的那个 s3 url 查台账
+   curl -s -X POST https://server.fmode.cn/api/functions \
+     -H "Content-Type: application/json" \
+     -H "X-Parse-Application-Id: ncloudmaster" \
+     -d '{"id":"THXAOGKiPK","token":"<你的token>","params":{"action":"listByUrl","url":"<刚发的url>"}}'
+   ```
+   期望 `count >= 1`
+
+4. **贴出这个 listByUrl 的原始返回**
+
+如果 count 还是 0,就是登记代码没走到真实路径。
+
+---
+
+## 顺带把剩下三项也确认了
+
+### A. 延迟清理
+```bash
+grep -n "os.remove\|CLEANUP_LOCAL\|DEFER-CLEANUP" /opt/data/scripts/*.py | head
+ls -la /opt/data/scripts/ | grep -iE "gc|cleanup"
+```
+
+### B. `fmode.kind`
+```bash
+grep -rn "fmode.kind" /opt/data/scripts/ /opt/data/plugins/ | head
+```
+
+### C. 网关防复发
+```bash
+head -20 /run/service/gateway-default/finish
+grep -rn "FINISH-GUARD" /opt/data/patches/ /opt/data/plugins/ 2>/dev/null | head
+```
+
+---
+
+## 纪律
+
+- **不要用探测脚本证明完成**——那只能证明函数能用,证明不了真实路径
+- 验证要贴 **listByUrl 的原始返回**
+- 做不到就说卡在哪

+ 174 - 0
docs/03-interface/指令-kangshifu-推进6和7.md

@@ -0,0 +1,174 @@
+## 台账的事先放一放,专注做 6 和 7
+
+你的排查结论我认可:**台账数据是真的,8 条都在,只是 user 桶不同**(你用自己的 token 登记,App 用用户 token 查)。
+
+这个问题要改架构,先不纠结。**6 和 7 是插件层,完全独立,现在就做。**
+
+---
+
+# 第 6 项:`fmode.kind='process'`
+
+## 要做的
+
+新建 `/opt/data/plugins/plugin-matrix-process-notice/`:
+- `__init__.py`
+- `plugin.yaml`
+
+## 核心逻辑(照 lj-test)
+
+**patch `MatrixAdapter._apply_relation_metadata`**(每条出站消息必经,且在 `send_message_event` 之前):
+
+```python
+# 伪代码
+def _apply_relation_metadata(content, ...):
+    body = content.get("body", "")
+    if 是工具进度行(body):
+        content["fmode.kind"] = "process"
+    else:
+        content.pop("fmode.kind", None)   # ← 清掉上游残留的 final
+    return 原逻辑(content, ...)
+```
+
+## 🔴 三个关键点(lj-test 踩过的坑)
+
+### 坑 1:必须从 `platform_registry` 取真类
+
+```python
+# 正确
+registry = ...get("matrix")
+cls = registry.adapter_factory()
+
+# 错误(patch 打在空气里)
+from plugins.platforms.matrix.adapter import MatrixAdapter
+```
+
+运行时真类名是 `hermes_plugins.matrix_platform.adapter.MatrixAdapter`。
+
+**你之前在出站那步已经解决过这个,复用同一套取类逻辑。**
+
+### 坑 2:只写 `process`,绝不写 `final`
+
+lj-test 原话:
+> 「出口层无法可靠区分终态,写错的 `final` 会让 App 把工具输出当正文直出。」
+
+而且**要清掉上游可能残留的 `final`**。
+
+### 坑 3:插件必须注册真实 hook
+
+`plugin.yaml` 里 `hooks: []` + 纯 monkey-patch 的插件,**loader 会直接跳过,`register()` 根本不执行**。
+
+参考 `plugin-tips-cn` 的写法。
+
+## 配置
+
+`config.yaml`:
+```yaml
+plugins:
+  enabled:
+    - plugin-matrix-process-notice
+  entries:
+    plugin-matrix-process-notice:
+      allow_tool_override: false
+```
+
+## 验证
+
+1. 重启网关
+2. 跑一个工具调用任务
+3. 从 Matrix API 回读工具进度消息,看 `content["fmode.kind"]`
+
+**判据**:
+- 工具进度行:`fmode.kind === "process"`(**扁平键,不是嵌套**)
+- 叙述正文:**没有** `fmode.kind` 这个键
+
+---
+
+# 第 7 项:网关防复发
+
+## 要做的
+
+新建 `/opt/data/plugins/plugin-gateway-finish-guard/`:
+- `__init__.py`(内嵌 `NEW_FINISH` 全文 + `install_guard()` + **import 期执行**)
+- `plugin.yaml`
+
+## 核心逻辑(照 lj-test)
+
+**在 import 期重写 `/run/service/gateway-default/finish`**:
+
+```bash
+#!/command/with-contenv sh
+# FMODE-FINISH-GUARD v2
+case "$1" in
+  78) exit 125 ;;                        # 不重启
+  *) 
+    if [ -f /opt/data/state/gateway-stopped-by-operator ]; then
+      exit 125                            # 人为停止,不重启
+    fi
+    exit 0                                # 其余(含 exit 0)→ 重启
+    ;;
+esac
+```
+
+## 为什么必须这样做(lj-test 原话)
+
+> 「`/run/service/*/finish` 是 **tmpfs**,且上游 `container_boot._register_service()` **每次启动 rmtree 重写**,
+> 单改活不过重启;所以把补丁正文内嵌进 `/opt/data` 持久卷上的插件,在 **import 期**重写。」
+
+## 🔴 关键点
+
+1. **`plugin.yaml` 不需要 `hooks:` 段**——触发靠 import 期
+2. **停机唯一合法方式**:`touch /opt/data/state/gateway-stopped-by-operator`
+3. **验收必须用** `PATH="/command:$PATH" finish <code>`
+   - 否则 `with-contenv` 会假阴性
+
+## 配置
+
+```yaml
+plugins:
+  enabled:
+    - plugin-gateway-finish-guard
+  entries:
+    plugin-gateway-finish-guard:
+      allow_tool_override: false
+```
+
+## 验证
+
+```bash
+# 1. 看当前 finish 内容
+cat /run/service/gateway-default/finish | head -5
+# 应包含 FMODE-FINISH-GUARD v2
+
+# 2. 测试判据(🔴 必须带 PATH)
+PATH="/command:$PATH" /run/service/gateway-default/finish 0; echo "exit=$?"   # 期望 0
+PATH="/command:$PATH" /run/service/gateway-default/finish 78; echo "exit=$?"  # 期望 125
+```
+
+**判据表**:
+
+| 输入 | 期望退出码 | 含义 |
+|---|---|---|
+| `0` | **0** | 重启(关键修复) |
+| `1` | 0 | 重启 |
+| `78` | 125 | 不重启 |
+| 有 operator 标记 | 125 | 人为停止 |
+
+---
+
+# 执行顺序
+
+| 序 | 任务 | 说明 |
+|---|---|---|
+| 1 | 第 6 项(fmode.kind) | 先做,改动小 |
+| 2 | 第 7 项(网关防复发) | 重启后一起验证 |
+
+**每项做完单独报,贴原始日志。**
+
+---
+
+# 纪律
+
+- 取真类用 `platform_registry`
+- 插件要注册真实 hook(否则不加载)
+- 验证贴原始输出
+- 做不到就说卡在哪

+ 69 - 0
docs/03-interface/指令-kangshifu-查hook日志.md

@@ -0,0 +1,69 @@
+## 台账查不到:先看 hook 日志里 `HUB` 那行的 `detail=`
+
+我从 App 侧核实:`listBySession` 对你两个房间都是 **count: 0**。
+
+你说「台账已登记 id=YNNgkgpEHL」「listByUrl count: 1」,我查不到。可能有三类原因,**先看日志判定是哪一类**。
+
+---
+
+## 请贴出 hook 日志里所有 `HUB` 行
+
+```bash
+grep -n "HUB" /opt/data/logs/*.log 2>/dev/null | tail -20
+grep -rn "HUB\|SKIP-REGISTER\|NO-THREADROOT\|register" /opt/data/cache/deliverable-hook/ 2>/dev/null | tail -10
+ls -la /opt/data/logs/ | head
+```
+
+**重点看 `detail=` 后面是什么。**
+
+---
+
+## 三类可能(lj-test 总结的)
+
+| # | detail | 含义 | 修法 |
+|---|---|---|---|
+| 1 | `no-threadRoot` | hook 子进程里 `HERMES_SESSION_ID` 为空 | 探测脚本能成功是因为手动传了 session;真实投递时 hook 子进程没有这个变量 |
+| 2 | **`roomId required`** | `HERMES_SESSION_CHAT_ID` 是空串 | 云函数侧校验拒绝。**最像你的现象**:url 对、文件发得出去、台账 0 条 |
+| 3 | agentId 不一致 | 登记用的 agentId ≠ 查询用的 | 台账按 agentId 过滤也会 0 条 |
+
+---
+
+## 关键:还有一层「user 隔离」
+
+云函数 `fmode-file-hub` 的机制是:
+
+```js
+var token = _sessionToken();
+var user = await _getUser(token);
+...
+q.equalTo('user', user);     // ← 谁调的,就记在谁名下;查询也只查自己的
+```
+
+**所以还要确认一件事:你登记时用的 token 是哪个账号的?**
+
+```bash
+grep -nE "FMODE_SESSION_TOKEN|FMODE_REPORT_TOKEN" /opt/data/.env | sed 's/=.*/=<有值>/'
+```
+
+如果这个 token 属于**容器服务账号**,那 App 用**用户 token** 查就是查不到——**两边是不同的 user 桶**。
+
+lj-test 用的是 `FMODE_SESSION_TOKEN`,我需要你确认你的 token 与它的归属是否一致。
+
+---
+
+## 请回答
+
+1. `HUB` 日志的 `detail=` 是什么?
+2. 登记用的 token 是 `FMODE_SESSION_TOKEN` 还是别的?它属于哪个账号?
+3. 你之前说 `listByUrl count: 1`,是用**哪个 token** 查的?
+
+**这三个问题回答清楚,就能定位到底卡在哪。**
+
+---
+
+## 顺便:5-7 的清单已发给你
+
+上一条消息里有 lj-test 的完整实现清单(延迟清理 / `fmode.kind` / 网关防复发),
+**那三项可以同时推进**,不用等台账。
+
+但台账这个「user 隔离」的问题必须先搞清楚——否则你可能一直在给一个 App 看不到的桶写数据。

+ 135 - 0
docs/03-interface/指令-kangshifu-登记应放hook层.md

@@ -0,0 +1,135 @@
+## 找到根本差异了:你把登记放错了架构层
+
+我问了 lj-test(已跑通的容器),它的登记机制是这样的:
+
+### lj-test 的调用链(真实行号)
+
+```
+config.yaml:79  transform_llm_output → python3 /opt/data/scripts/fmode-deliverable-hook.py
+  → handle_transform_llm_output  (882 行)
+    → upload_and_register        (738 行)
+      → _hub_register(p, url, thread_root, room_id)   (477-489 行)
+        → _hub_call({action:"registerFile"})          (445-452 行)
+```
+
+**关键两点:**
+
+1. **登记发生在 `transform_llm_output` 阶段(Python hook 脚本),不是 adapter 层**
+2. **`post_tool_call` 链路里完全没有登记代码**,它只负责把文件放进队列
+
+lj-test 的原话:
+> 「在 transform_llm_output 阶段,而且是『上传成功 + 回读 md5 一致』之后立刻登记,不在 post_tool_call 里。」
+
+---
+
+## 这解释了你的困境
+
+你现在把登记挂在 **adapter 的 `_upload_and_send` monkey-patch** 里。
+
+你自己也发现了:
+> 「02:11 重启之后,`出站直链` 和 `台账` 都是 0 条,说明真实 MEDIA 投递**一次都没走我的补丁**」
+
+**根因:monkey-patch 依赖「打补丁的类实例 == 真实路径的实例」,这个假设不成立。**
+
+而 hook 是**独立进程,每次执行都重新加载**——不存在「类实例」的问题。
+
+---
+
+## 你要做的:把登记搬到 hook 层
+
+### 你需要先有 `fmode-deliverable-hook.py`
+
+你之前说过「没有这个文件」。**这是核心缺失。**
+
+参照 lj-test 的结构,你需要:
+
+1. **一个 hook 脚本**(`/opt/data/scripts/fmode-deliverable-hook.py` 或你自己命名的)
+2. **在 `config.yaml` 里注册 `transform_llm_output` hook**,指向它
+3. 脚本内部流程:
+
+```
+① 从 payload 拿 candidates(本轮产出的文件路径)
+② 上传到 OBS 拿 s3 url
+③ 回读 md5 比对
+④ md5 一致 + 有 url → 调 registerFile 登记
+⑤ 把 s3 url 补回正文(替换/追加)
+```
+
+### registerFile 调用
+
+```python
+_hub_call({
+  "action": "registerFile",
+  "roomId": <从 payload 或 state.db 取>,
+  "threadRoot": <三级回落,取不到传空>,
+  "agentId": "agent-node-kangshifu",
+  "name": <干净文件名>,
+  "url": <s3 直链>,
+  "key": <完整存储键>,
+  "uploadedBy": "agent"
+})
+```
+
+### 关键:roomId 从哪来
+
+lj-test 的 `_hub_register` 签名是 `(p, url, thread_root, room_id)` —— **room_id 是从上游传进来的**。
+
+你要在 `handle_transform_llm_output` 里拿到当前会话的 room_id,再往下传。
+
+---
+
+## 另一个重要发现
+
+lj-test 说:
+
+> 「登记(`_hub_register`)**不读 pushed**。pushed 只在 transform hook 里用于两件事:正文 URL 白名单、避免重复上传」
+
+**所以你之前纠结的 `pushed[]` 空的问题,不影响登记本身。**
+
+---
+
+## 建议的做法(两条路选一条)
+
+### 路线 A(推荐):照搬 lj-test 的 hook 结构
+- 从 lj-test 那边拿 `fmode-deliverable-hook.py`
+- 改成你的 agentId / 路径
+- 在 `config.yaml` 注册 `transform_llm_output`
+
+### 路线 B:在现有插件里加 transform hook
+- 如果已经有 `plugin-deliverable-inject` 的 `transform_llm_output`
+- 在它的回调里,**上传成功后直接调 registerFile**
+- 不依赖 adapter patch
+
+**两条路都不应该把登记挂在 adapter 层。**
+
+---
+
+## 验收(我来做)
+
+1. 你回复正文写 `MEDIA:/opt/data/xxx.txt`
+2. 网关真实投递
+3. 我从 App 侧查:
+   - `listByUrl(<那个 s3 url>)` → **count ≥ 1**
+   - `uploadedBy === 'agent'`
+   - `roomId` 对得上
+
+**贴出 listByUrl 的原始返回,不要用探测脚本。**
+
+---
+
+## 顺带
+
+请把剩下三项也确认了:
+
+```bash
+# A. 延迟清理
+grep -n "os.remove\|CLEANUP_LOCAL\|DEFER-CLEANUP" /opt/data/scripts/*.py | head
+ls /opt/data/scripts/ | grep -iE "gc|cleanup"
+
+# B. fmode.kind
+grep -rn "fmode.kind" /opt/data/scripts/ /opt/data/plugins/ | head
+
+# C. 网关防复发
+head -20 /run/service/gateway-default/finish
+grep -rn "FINISH-GUARD" /opt/data/patches/ /opt/data/plugins/ 2>/dev/null | head
+```

+ 64 - 0
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -10,6 +10,16 @@ import { ChatService } from '../../../core/chat.service';
 import { Agent, SemanticTone } from '../../../core/api/models';
 import { resolveAgentState } from '../../../core/agent-state';
 
+/**
+ * `pendingSessionId` 的哨兵值:建房已开始、但会话 id 还没拿到(2026-11-14)。
+ *
+ * 用它区分三种状态:
+ *   · `null`            → 没有在建的会话
+ *   · `PENDING_SENTINEL` → 正在建,id 未知
+ *   · 其它字符串        → 正在建,id 已知
+ */
+const PENDING_SENTINEL = '__pending__';
+
 /**
  * 「新建对话」模态框(全局单例,挂在外壳上)
  *
@@ -376,12 +386,49 @@ export class NewSessionModalComponent {
    * 建房失败**不阻塞**:`startBlankWithRoom` 内部静默降级为共享房间,
    * 用户仍能正常对话(只是状态隔离降级)。绝不能因为建房失败让人进不去。
    */
+  /**
+   * 本次新建出来、但用户可能中途取消的会话 id(2026-11-14 修复)。
+   *
+   * ## 问题
+   *
+   * 用户实测:点「新建对话」→ 选容器 → 建房过程中点「取消」→
+   * **左侧仍然出现那条新会话**。
+   *
+   * ## 根因
+   *
+   * `startBlankWithRoom()` 是「先本地建会话 → 再建房」。用户在建房途中取消时,
+   * 会话已经在本地列表里了;而 `close()` 里的 `pruneEmpty(当前会话id)` 会**保留它**
+   * (因为它就是当前会话)。
+   *
+   * ## 现在的做法
+   *
+   * 建房开始时记下会话 id;建房成功(准备跳转)就清空标记;
+   * 用户中途取消时,若标记还在,说明这条是「没建成也没进去」的残留,删掉。
+   */
+  private pendingSessionId: string | null = null;
   protected async createIn(agentId: string): Promise<void> {
     if (this.preparing()) return; // 防重复点击
     const target = this.service.workspaceId();
     this.preparing.set(true);
     try {
+      /*
+       * 🔴 先占位一个哨兵值(2026-11-14)。
+       *
+       * `startBlankWithRoom()` 内部是「先本地建会话 → 再建房」,
+       * 但它在整个过程中是 **await** 的 —— 这期间用户完全可能点「取消」。
+       * 那一刻我们还没拿到会话 id,所以先设一个哨兵,
+       * `close()` 看到哨兵就知道「正在建、要清残留」。
+       *
+       * 真正的 id 拿到后再覆盖它。
+       */
+      this.pendingSessionId = PENDING_SENTINEL;
       const s = await this.chat.startBlankWithRoom(agentId, target);
+      /* 拿到真实 id:若用户已取消(标记被清),说明这条不该存在,直接删 */
+      if (this.pendingSessionId === null) {
+        if (s) this.chat.sessions.remove(s.id);
+        return;
+      }
+      this.pendingSessionId = s?.id ?? null;
       /*
        * 🔴 不要在这里 pruneEmpty()(2026-11-13 修复)。
        *
@@ -390,6 +437,8 @@ export class NewSessionModalComponent {
        * 清理空壳的时机是「离开/取消」,不是「新建成功」。
        */
       this.closeWithoutPrune();
+      /* 正常进入:清掉标记,这条会话要保留 */
+      this.pendingSessionId = null;
       if (!s) {
         /* 容器未就绪(连接失败等):退回旧的「待建」地址,由聊天页给出错误提示 */
         const qs = target ? `?ws=${encodeURIComponent(target)}` : '';
@@ -451,6 +500,21 @@ export class NewSessionModalComponent {
      *    正在用的会话也被删了(这是上一版的 bug:无条件 pruneEmpty() 把
      *    刚建好、还没发消息的会话误删,左栏直接消失)。
      */
+    /*
+     * 🔴 取消正在进行的建房时,把残留会话删掉(2026-11-14 修复)。
+     *
+     * `pendingSessionId` 非空说明「建房还没走完用户就点了取消」:
+     *   · 若是哨兵 → 会话 id 还没拿到,但 `startBlankWithRoom` 内部已创建,
+     *     由它完成后自己检测标记被清并删除(见 `createIn` 的二次检查)
+     *   · 若是真实 id → 直接删
+     *
+     * 这条会话是「没建成也没进去」的残留,用户不该在左栏看到它。
+     */
+    if (this.pendingSessionId && this.pendingSessionId !== PENDING_SENTINEL) {
+      this.chat.sessions.remove(this.pendingSessionId);
+    }
+    /* 清空标记:让仍在 await 的 createIn 知道用户已取消 */
+    this.pendingSessionId = null;
     this.chat.sessions.pruneEmpty(this.chat.sessionId() ?? undefined);
     this.service.close();
   }

+ 49 - 1
src/app/core/chat.service.spec.ts

@@ -1745,6 +1745,55 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
       expect(svc.turnSettledLocally()).toBeFalse();
     });
   });
+
+  /*
+   * 持有条在会话空闲后自动清除(2026-11-14 用户实测修复)。
+   *
+   * 用户截图:会话早已结束、成果都出来了,输入框上方仍挂着
+   * 「任务执行中 · 确认发送将中断当前任务」的持有条。
+   *
+   * 根因:「不点不发」改动移除了 `armHeldAutoSend()`,但没加替代的清理机制。
+   */
+  describe('持有条自动清除', () => {
+    const awaiting = (): { set(v: number): void } =>
+      (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince;
+    const held = (): { set(v: unknown): void } =>
+      (svc as unknown as { _heldMessage: { set(v: unknown): void } })._heldMessage;
+
+    it('🔴 会话空闲后,持有条自动撤回(回到草稿,不自动发出)', async () => {
+      (environment as { useMockData: boolean }).useMockData = false;
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      await svc.open('agent-node-lj-test');
+
+      awaiting().set(Date.now());
+      held().set({ text: '被拦住的那条', at: Date.now() });
+      expect(svc.sessionBusy()).withContext('前置:判忙').toBeTrue();
+
+      awaiting().set(0);
+      await new Promise((r) => setTimeout(r));
+
+      expect(svc.heldMessage()).withContext('空闲后持有条必须消失').toBeNull();
+      expect(svc.draft()).withContext('内容回到输入框,不自动发出').toBe('被拦住的那条');
+    });
+
+    it('草稿已有新内容时,旧持有内容直接丢弃不覆盖', async () => {
+      (environment as { useMockData: boolean }).useMockData = false;
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      await svc.open('agent-node-lj-test');
+
+      awaiting().set(Date.now());
+      held().set({ text: '旧的那条', at: Date.now() });
+      svc.setDraft('用户正在写的新内容');
+
+      awaiting().set(0);
+      await new Promise((r) => setTimeout(r));
+
+      expect(svc.heldMessage()).toBeNull();
+      expect(svc.draft()).withContext('不能覆盖用户正在写的内容').toBe('用户正在写的新内容');
+    });
+  });
 });
 
 /**
@@ -1769,4 +1818,3 @@ describe('isAttachmentEcho —— 附件 body 判定', () => {
     expect(isAttachmentEcho(undefined, 'report.md')).toBeFalse();
   });
 });
-

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

@@ -568,6 +568,35 @@ export class ChatService {
      * 理由:自动发会让用户失去对「是否打断」的控制权,
      * 而且提示条上的「打断发送」按钮在自动发模式下语义混乱。
      */
+    /*
+     * 🔴 会话转为空闲 → 自动清除持有条(2026-11-14 用户实测修复)。
+     *
+     * ## 问题
+     *
+     * 截图实证:会话早已结束、成果都出来了,输入框上方仍挂着
+     * 「任务执行中 · 确认发送将中断当前任务」的持有条。
+     *
+     * ## 根因
+     *
+     * 「不点不发」改动移除了 `armHeldAutoSend()`(空闲后自动发),
+     * **但没加替代的清理机制** —— `_heldMessage` 一旦写入就再没人清,
+     * 用户不点任何按钮它就永远挂着。
+     *
+     * ## 现在的行为
+     *
+     * 会话空闲时把持有条**自动撤回**(内容回到草稿),而不是自动发出。
+     * 这与用户「不点就不发」的裁决一致:不发,但要有个收尾。
+     *
+     * 若草稿已有内容(用户又打了别的),不覆盖它 —— 旧的持有内容直接丢弃,
+     * 因为用户显然已经在写新的了。
+     */
+    effect(() => {
+      const busy = this.sessionBusy();
+      const held = untracked(() => this._heldMessage());
+      if (busy || !held) return;
+      this._heldMessage.set(null);
+      if (!untracked(() => this._draft()).trim()) this._draft.set(held.text);
+    });
     /**
      * 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
      *