DeepSeek Harness 实战 05|让两个编码 Agent 共用一份长期记忆原创
# 让两个编码 Agent 共用一份长期记忆:一次全程无报错的静默失效
把一个持久记忆库接到 DeepSeek Harness(下称 dsh)上,最直觉的做法是:记忆库自带转录监听器,那就把它指向 dsh 的会话目录,让它自己去读。配置字段全对,监听器启动无异常,日志一行报错没有,会话文件明明白白躺在那个目录里——然后你等一小时,数据库里一条记忆都没有。
问题不在配置,在于监听器跑在 Bun 上,而 Bun 的递归 fs.watch 不会给 watch 启动之后才新建的目录挂 inotify 监听;dsh 恰好每个会话新建一个目录。这不是多等一会儿能赢的竞态,是结构性看不见。这篇把这个集成从头做通,连同另外七个同样不报错的失效点,最后给出一套把每条坑变成可失败断言的验证脚本。全部代码开源在 Carry00/dsh-claude-mem (opens new window)。
版本说明
本文基于 dsh developer preview 与 claude-mem v13.x 实测。dsh 的会话记录格式(session.v3.jsonl)仍在演进,字段路径以你本机的实际转录为准——文中给了逐字段核对的方法。
# 1. 会话结束即失忆,换个工具再失忆一次
编码 Agent 的记忆有两层各自独立的丢失。
第一层是会话级的。 上次踩过的坑、定过的方案、排查出来的根因,新开一个会话一概不知道,你得重讲一遍。这不是上下文窗口不够——窗口再大,会话一结束照样散掉。它需要的是一个会话之外的存储。
第二层是工具级的。 假设你已经解决了第一层:某个记忆库(本文用 claude-mem,一个把会话摘要成 observation 并提供检索的本地 SQLite 方案)替你记住了几个月的工作。但那是另一个 harness 的库。你切到 dsh,等于换了一个大脑:两边各记各的,谁也读不到谁。
这两层合起来的效果是:你在 A 工具里花两小时排查出的根因,在 B 工具里不存在。工具越多越严重。
所以目标不是「给 dsh 装个记忆」,而是让 dsh 和已有工具共用同一个记忆池——问 dsh「上次这个报错的根因是什么」,它能捞到你在另一个 harness 里的排查过程,反过来也一样。
# 2. 读和写是两条完全不同的通路
一个共享记忆是两个方向,机制完全不同,可以只做一半:
| 方向 | 做什么 | 机制 |
|---|---|---|
| 读 dsh → 记忆 | agent 能检索历史上记录过的一切 | 把记忆库的 MCP server 注册成 dsh 的 MCP client |
| 写 dsh → 记忆 | dsh 自己的会话被摘要入库,日后可检索 | 给记忆库的转录监听器喂一份 dsh 会话格式的 schema |
整体形状:
┌──────────────────────────────┐
读 ────────────│ 记忆库 MCP server │◀── 另一个 harness 写入的同一个库
│ (mcp-server.cjs, stdio) │
└──────────────┬───────────────┘
│ mcp__<name>__search …
┌──────────────▼───────────────┐
│ dsh │
└──────────────┬───────────────┘
│ 写 session.v3.jsonl
${DSH_HOME}/sessions/<workspace>/<session-id>/session.v3.jsonl
│
│ 静置后硬链接(timer,每 2 分钟)
▼
${DSH_HOME}/sessions-cmem/<session-id>.jsonl ← 扁平、稳定、早已存在的目录
│
┌──────────────▼───────────────┐
写 ────────────│ 记忆库 transcript watcher │──▶ observations
└──────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
读侧是纯配置,半小时能通。写侧那个多出来的硬链接跳板,就是本文的主要内容——它看起来像是脱裤子放屁,实际是绕开一个没法从配置层解决的运行时限制。
# 3. 读侧:把记忆库挂成 dsh 的 MCP client
先做读侧,因为它有一个极其好用的早期验证点:MCP server 能不能握手,一条命令就能问出来,不需要动 dsh。
# 准备:探测路径,不要写死
记忆库的安装路径因安装方式而异,而且同一台机器上常常并存多个版本(一个已安装的 marketplace 副本,加若干版本锁定的 cache 副本)。探测,别假设:
CM_HOME="${CLAUDE_MEM_HOME:-$HOME/.claude-mem}" # 数据库与配置
CC_CONFIG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" # 宿主配置目录
# 优先取 marketplace 安装副本,而不是 cache 里的旧版本
cands=$(find "$CC_CONFIG" -name mcp-server.cjs 2>/dev/null)
pick=$(printf '%s\n' "$cands" | grep marketplaces | head -1)
[ -z "$pick" ] && pick=$(printf '%s\n' "$cands" | head -1)
CM_PLUGIN=$(dirname "$(dirname "$pick")")
echo "$CM_PLUGIN"
2
3
4
5
6
7
8
9
这一步不做,最常见的后果是你接上了一个几个版本之前的 cache 副本,工具列表和你读的文档对不上。
# 执行:先让 server 自己说话
任何 stdio MCP server 都可以用三行 JSON-RPC 直接问,不需要任何客户端:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| CLAUDE_CONFIG_DIR="$CC_CONFIG" CLAUDE_PLUGIN_ROOT="$CM_PLUGIN" \
timeout 30 node "$CM_PLUGIN/scripts/mcp-server.cjs" 2>/dev/null | head -2
2
3
4
5
6
预期输出(第一行是握手,第二行是工具清单):
{"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"claude-mem","version":"13.24.1"}},"jsonrpc":"2.0","id":1}
{"result":{"tools":[{"name":"important_workflow",...},{"name":"search",...
2
这条命令还顺带告诉你它到底是哪个版本——上一步选错副本的话,这里的 version 会当场露馅。
# 执行:写进 dsh 的 patch 层
dsh 的家目录级 patch(${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml)对所有 profile 生效,优先级高于 profile 层。插入一个 MCP client 条目:
- insert:
- id: claude-mem-mcp
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: claude_mem
transport: stdio
command: node
args:
- <CM_PLUGIN>/scripts/mcp-server.cjs
env:
CLAUDE_CONFIG_DIR: <CC_CONFIG>
CLAUDE_PLUGIN_ROOT: <CM_PLUGIN>
2
3
4
5
6
7
8
9
10
11
12
两个字段是承重的,写错了工具根本不会出现,且没有任何提示:
- 直接指向
mcp-server.cjs,不要指向包自带的启动器。 那类启动器普遍会做路径探测,而它探的那些路径在 dsh 的进程启动环境下解析不出来。 env必须显式声明。 dsh 的 stdio 桥接会把环境变量名匹配/KEY|PASSWORD|SECRET|TOKEN/i的、以及所有DSH_*的,全部洗掉再传给子进程。这是个好的默认(防止把凭据泄漏给第三方 MCP server),代价是依赖环境变量的 server 必须在这里把需要的名字一个个列出来。
# 验证:必须做阳性对照
开一个新 dsh 会话,让它检索一个你确知库里有的词,并要求它报出它实际调用的工具名和第一条结果的标题。
不要接受「没搜到」作为通过。 这是本文最贵的一条经验:agent 会把一条坏掉的管道如实包装成「未找到相关结果」,还附上一段自信的总结。用一个必然命中的词(任意一个你做过的项目名)做阳性对照,看到真实标题才算通。
# 4. 写侧:那个看起来多余的硬链接跳板
现在是主菜。
# 症状:全对,且零入库
记忆库的转录监听器配置长这样(简化):
{
"watches": [
{ "name": "dsh", "schema": "dsh",
"path": "/home/<user>/.dsh/sessions/**/session.v3.jsonl",
"startAtEnd": false }
]
}
2
3
4
5
6
7
路径对、通配符对、schema 也对。重启监听器进程,跑几个 dsh 会话,数据库里什么都没有。日志里没有 error,没有 warning,watcher 状态文件里连一个字节偏移量都没记。
# 根因:Bun 的递归 fs.watch 不认后建目录
监听器进程跑在 Bun 上。Bun 的 fs.watch({recursive:true}) 在启动时对当时已存在的目录树逐个挂 inotify watch;之后新建的子目录,不会被自动补挂。
而 dsh 的会话布局是一个会话一个新目录:
${DSH_HOME}/sessions/<workspace-slug>/<session-id>/session.v3.jsonl
于是每一个新会话都诞生在一个 watcher 从未挂过监听的目录里。文件被创建、被追加、被写完,inotify 全程没有事件产生。这不是时序问题——再等多久都不会到。
这个失效模式的恶劣之处在于三重静默:Bun 不报错(它确实按规范挂了当时存在的目录)、监听器不报错(它没收到事件,也就无从知道漏了什么)、dsh 不报错(它压根不知道有人在读它的日志)。
# 解药:把「已完稿」的会话发布到一个早就存在的扁平目录
既然「后建的目录看不见」,那就不要让 watcher 去看后建的目录。改成:让一个定时任务,把已经静置(连续 N 秒没被写过,即会话已停笔)的转录,发布到一个 watch 启动时就已经存在的扁平目录里。那个目录深度为 1 的 create 事件,投递是可靠的。
#!/bin/bash
set -euo pipefail
SRC="$HOME/.dsh/sessions"
DST="$HOME/.dsh/sessions-cmem"
SETTLE_SECS="${DSH_CMEM_SETTLE_SECS:-120}"
mkdir -p "$DST"
[ -d "$SRC" ] || exit 0
find "$SRC" -name 'session.v3.jsonl' -type f -mmin +$((SETTLE_SECS/60)) | while read -r f; do
sid=$(basename "$(dirname "$f")")
target="$DST/$sid.jsonl"
[ -e "$target" ] && continue
ln -f "$f" "$target" 2>/dev/null || cp -f "$f" "$target"
done
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
三个设计点值得说清楚:
- 用硬链接,不用拷贝。 同一文件系统,不占额外磁盘;更重要的是,会话若被恢复并继续追加,硬链接看得到追加内容,watcher 的字节偏移量顺势往前走即可,不需要重新发布。跨文件系统时
ln会失败并回退到cp——功能上没问题,但从此刻起追加就跟不上了,这是个要知道的降级。 - 只发布静置的会话。 正在写的会话不发布,避免 watcher 读到半截。代价是延迟。
- 幂等。 目标已存在就跳过,脚本可以任意频率重复跑。
挂到一个两分钟的 systemd user timer 上:
# ~/.config/systemd/user/dsh-cmem-export.timer
[Timer]
OnBootSec=3min
OnUnitActiveSec=2min
AccuracySec=30s
[Install]
WantedBy=timers.target
2
3
4
5
6
7
8
systemctl --user daemon-reload
systemctl --user enable --now dsh-cmem-export.timer
2
没有 systemd 的话 */2 * * * * $HOME/.dsh/cmem-export.sh 等效。
这套设计的代价要说明白:会话停笔到记忆可检索之间有 2–4 分钟延迟(最多 120 秒静置 + 最多 120 秒 timer 间隔)。这是为可靠性主动付的成本,不是缺陷。要更快就调小 SETTLE_SECS 和 timer 间隔,但静置窗口小于会话的思考停顿时,就会开始发布半截会话。
# 然后把 watcher 指向扁平目录
{
"watches": [
{ "name": "dsh", "schema": "dsh",
"path": "/home/<user>/.dsh/sessions-cmem/*.jsonl",
"startAtEnd": false }
]
}
2
3
4
5
6
7
两个设置必须正确,各自对应一个后面会讲的坑:path 指向扁平目录而不是真实会话树;startAtEnd 必须是 false。
配置里只能用绝对路径——~ 在这个文件里不会被展开,展不开的路径会让 watcher 在计算「通配符之前最深的那个真实祖先目录」时拿到空值,然后同样静默地什么都不做。
最后按这个顺序收尾:
mkdir -p "$HOME/.dsh/sessions-cmem" # 先建目录
claude-mem restart # 再重启 worker
2
顺序反了的话,watch 会挂在一个不存在的目录上,静默失效且永不重试。
# 5. 会话格式:字段路径怎么核对
写侧还有一半工作量在 schema 上——告诉监听器 session.v3.jsonl 的每种记录长什么样。先看一眼你本机的实际类型分布:
python3 -c "
import json,collections
c=collections.Counter()
for l in open('$HOME/.dsh/sessions-cmem/<session-id>.jsonl'):
c[json.loads(l).get('type')]+=1
print(c)"
2
3
4
5
6
预期输出(一个典型短会话):
Counter({'user/message': 4, 'agent/inbox/spliced': 2, 'step/start': 2, 'session/title': 2,
'assistant/message': 2, 'step/end': 2, 'session': 1, 'permission/preset': 1,
'sandbox/mode': 1, 'approval/policy': 1, 'turn/start': 1, 'system/message': 1,
'request/header': 1, 'request/context': 1, 'tool/call': 1, 'tool/result': 1,
'turn/end': 1})
2
3
4
5
十七种记录,只需要映射五种。几个反直觉的点:
用户消息不能按 type 匹配。 同样形状的记录也会出现在 agent/inbox/spliced 批次里,所以匹配条件应该是 data.source.kind == "user":
{"type":"user/message","seq":8,"data":{
"content":[{"type":"text","text":"..."}],
"source":{"kind":"user"},"role":"user","id":"..."}}
2
3
助手消息的 content[0] 往往不是回复。 它常常是一个 reasoning 块,回复在 content[1]:
{"type":"assistant/message","data":{"turn":1,"step":1,
"message":{"role":"assistant","content":[
{"type":"reasoning","text":"..."},
{"type":"text","text":"..."}]}}}
2
3
4
所以取值必须先试 content[1].text、失败再回退 content[0].text。顺序反了的结果是:你的记忆库存的全是模型的思维链,而不是它给出的答案。这个错误不会报错,只会让你日后检索到一堆自言自语。
工具调用的 arguments 是字符串不是对象,调用与结果靠 callId / toolCallId 配对:
{"type":"tool/call","data":{"callId":"call_...","name":"mcp__claude_mem__search",
"arguments":"{\"query\": \"...\"}"}}
2
故意不映射的三种:request/header、request/context、session/title-llm-request。它们装的是每一轮发给模型的组装后完整请求——系统提示词、全部工具定义、以及重新序列化的整段历史。映射它们会让存储爆炸,还会把系统提示词逐字抄进记忆库。实测确认:不映射这三种之后,观察记录里没有任何系统提示词文本。
# 6. 八个不报错的失效点
这个集成从头到尾没有一个失效点会报错。全表:
| 现象 | 真正的成因 |
|---|---|
| watcher 永远收不到任何会话 | Bun 递归 fs.watch 不给 watch 启动后新建的目录挂 inotify;dsh 每会话一目录 |
| watch 完全没动静,且无任何报错 | worker 启动时被监听目录不存在,静默失效且永不重试 |
| 配置里的路径看着没错 | 配置文件里的 ~ 不会被展开,必须绝对路径 |
| 长会话进得来,短会话一条不进 | startAtEnd: true,文件第一次被发现时已在末尾;而短会话被发布时早已停笔 |
| watcher 读不到内容 | dsh 默认把会话压成 .jsonl.zstd |
| dsh 每一次会话操作都抛异常 | 改明文后旧的 .jsonl.zstd 还留在 root 下——一个 session root 只能有一种编码 |
| 会话目录莫名搬家 | dsh 的 patch 会整体替换目标行的 config,没重述的字段直接丢失 |
| dsh 里根本看不到 MCP 工具 | stdio 桥接按 /KEY\|PASSWORD\|SECRET\|TOKEN/i 与 DSH_* 洗掉环境变量;env 未显式声明 |
其中三条值得展开。
startAtEnd 为什么必须是 false。 直觉上「从末尾开始读」是对的——避免把历史文件重读一遍。但在这个架构下,被发布到扁平目录的每一个文件本身就是一段完整的、已经结束的历史。设成 true 的结果是:文件第一次被发现时游标就在末尾,此后它不会再增长,于是贡献零条记录。长会话偶尔能侥幸赶上追加,短会话则系统性地全军覆没。从 0 字节读起才是正确语义。
编码单一性。 把会话从 zstd 改成明文,只改配置是不够的:只要 root 下还留着任何一个 .jsonl.zstd,dsh 的 create / open / stat / list 全部会抛异常。切换前必须把整个旧目录挪走:
mv ~/.dsh/sessions ~/.dsh/sessions-zstd-archive-$(date +%Y%m%d)
mkdir -p ~/.dsh/sessions
2
patch 是替换不是合并。 dsh 的 patch 条目会把目标行的 config 整体换掉。所以改压缩方式时,必须把原本就有的 root 一并重述:
- id: session-persistence-jsonl
config:
root: !!js dshHomePath('sessions') # 不重述这一行,会话目录就搬家了
compression: none
2
3
4
# 7. 把每条坑变成一个会失败的断言
上面八条如果只是写在文档里,下次还是会踩。正确做法是每条对应一个可执行的断言,让「装没装对」变成一个可以跑出来的答案,而不是靠感觉:
Gate 0 — MCP server 握手成功、search 工具在列(顺带打印版本)
Gate 1 — 明文转录 存在 session.v3.jsonl,且 root 下无 .jsonl.zstd 混杂
Gate 2 — 发布器 扁平目录存在、已有产物、timer active
Gate 3 — watcher path 指向扁平目录、startAtEnd 为 false、schema 已定义、
状态文件里有对应偏移量
Gate 4 — 数据真的落库 sdk_sessions 里有本平台的会话,且 observations 能 join 上
2
3
4
5
6
Gate 4 那两条 SQL 是最终裁决——前面全绿但这里为空,说明 schema 字段路径没匹配上:
DB=~/.claude-mem/claude-mem.db
sqlite3 "$DB" "select count(*) from sdk_sessions where platform_source='dsh';"
sqlite3 "$DB" "select count(*) from observations o
join sdk_sessions s using(memory_session_id)
where s.platform_source='dsh';"
2
3
4
5
完整脚本在 scripts/verify.sh (opens new window),任一 gate 失败即非零退出。
# 8. 隐私:默认采集了什么
转录里有你输入的一切,以及工具返回的一切——这是最宽的面:一次读文件把文件内容放进了转录,一条 shell 命令把它的输出原样放了进去。
会被读进记忆的:你的提示词、助手的可见回复、工具调用及其结果。不会被读进去的:系统提示词(在 system/message 与 request/header 里,两者都不映射)、每轮组装的完整请求、会话标题生成请求。
有一个反直觉的点值得单独说:dsh 会发出「合成的」用户消息——注入的 <system-reminder>、工作区 AGENTS.md 的内容、运行时上下文快照。它们带的也是 source.kind: "user",所以会被当成普通提示词读走。实际后果是你的工作区指令文件会被摘要进记忆。通常无害,但如果某个 AGENTS.md 里有不想被索引的东西,就要知道这件事。
对应的开关是在发布环节做排除——一个装满子串模式的排除文件,命中的会话根本不进扁平目录,也就永远到不了记忆库:
# ~/.dsh/cmem-export.exclude
/clients/
--some-workspace-slug--
2
3
排除在发布时判定。已经发布过的会话不会被追溯撤回,要停就删掉扁平目录里那个硬链接,并单独清理库里已有的记录。
# 9. 可复用要点
- 静默失效要靠断言发现,不能靠日志。 这个集成八个失效点零报错。凡是「配置看起来全对但没效果」的系统,唯一可靠的手段是为每个环节写一条会失败的断言,从最上游(server 能不能握手)逐段往下推。
- 文件监听不要假设递归就等于持续递归。 「recursive 为 true」在多数运行时里只承诺启动时的那棵树。只要被监听方是「一个任务一个新目录」的布局,就得先确认这一点,再决定架构——不然会浪费掉一整轮排查。
- 绕不开运行时限制时,改变输入的形状,而不是硬调参数。 后建目录看不见,那就别让它看后建目录:把完稿产物发布到一个稳定的扁平目录。多一跳、多两分钟延迟,换来的是可靠性从「随机」变成「确定」。
- 让 agent 自证时必须做阳性对照。 它会把一条坏掉的管道包装成「未找到相关结果」并附上自信的总结。任何「查了,没有」的结论,都要用一个必然命中的输入复跑同一条管道来验证。
# 10. 延伸阅读
完整可复现的实现开源在 Carry00/dsh-claude-mem (opens new window):安装器、验证脚本、配置模板,以及一份写给 AI agent 执行的作业手册(AGENTS.md)——把仓库交给任意编码 agent,说一句「读 AGENTS.md 装上」即可,路径全部探测而非写死。
本系列另外四篇:提示词分层与技能加载实测、三个 Harness 的解剖、五个核心心智模型、三处设计张力。
🤖 Agent 可直接解析的元数据块(点击展开)
{
"_meta": {
"doc_version": "2026-09-09",
"article_id": "dsh-05-shared-memory-with-claude-mem",
"profile_context": "any",
"estimated_setup_time": "60min",
"reference_repo": "https://github.com/Carry00/dsh-claude-mem"
},
"quick_start": {
"step_1": "探测路径:CM_HOME / CC_CONFIG / CM_PLUGIN,优先取 marketplace 副本而非 cache 副本",
"step_2": "用三行 JSON-RPC 直接向 mcp-server.cjs 发 initialize + tools/list,确认握手与版本",
"step_3": "归档旧的 .jsonl.zstd 会话,patch 里设 compression: none 并重述 root",
"step_4": "patch 里插入 MCP client 条目,直接指向 mcp-server.cjs,显式声明 env",
"step_5": "建扁平目录 sessions-cmem,装发布脚本与 2 分钟 timer",
"step_6": "watcher 的 path 指向扁平目录、startAtEnd 设 false、全绝对路径",
"step_7": "先确认扁平目录存在,再重启 worker(顺序不能反)"
},
"silent_failure_modes": [
"Bun 递归 fs.watch 不给 watch 启动后新建的目录挂 inotify —— 每会话一目录的布局结构性不可见",
"被监听目录在 worker 启动时不存在 —— watch 静默失效且永不重试",
"配置文件里的 ~ 不展开 —— 通配符祖先目录解析为空",
"startAtEnd: true —— 短会话系统性全部丢失",
"session root 混有 .jsonl.zstd —— dsh 所有会话操作抛异常",
"patch 整体替换 config —— 未重述的 root 导致会话目录搬家",
"stdio 桥接洗掉匹配 KEY|PASSWORD|SECRET|TOKEN 与 DSH_* 的环境变量 —— env 必须显式声明",
"assistant/message 的 content[0] 常是 reasoning 块 —— coalesce 顺序错则存的是思维链"
],
"safety_rules": [
"切换压缩方式前先整体归档旧会话目录,一个 session root 只能有一种编码",
"改 patch 时重述目标行原有的全部字段,patch 是替换不是合并",
"重启 worker 之前先确保被监听目录已存在",
"不要映射 request/header 与 system/message,否则系统提示词逐字进库",
"开启前评估工具结果入库的范围:读文件与 shell 输出会原样进转录",
"agent 报告『没搜到』一律做阳性对照后才采信"
],
"verification": {
"check_mcp_handshake": "printf 三行 JSON-RPC | node <CM_PLUGIN>/scripts/mcp-server.cjs,期望 serverInfo 与 tools 列表",
"check_plaintext_sessions": "find ~/.dsh/sessions -name 'session.v3.jsonl' | wc -l # 且 *.jsonl.zstd 计数为 0",
"check_publisher": "systemctl --user is-active dsh-cmem-export.timer # active;且扁平目录非空",
"check_watch_offsets": "读 watcher 状态文件,确认存在指向扁平目录的字节偏移量",
"check_db_ingested": "sqlite3 <db> \"select count(*) from observations o join sdk_sessions s using(memory_session_id) where s.platform_source='dsh';\" # 大于 0"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
AI Agent 使用指南:
- 当用户说「记忆库一条都没进,但配置是对的」→ 按
silent_failure_modes前三条依次排除:监听的是不是后建目录、目录在 worker 启动时存不存在、路径有没有用~ - 当用户说「长会话有记录、短会话没有」→ 直接查
startAtEnd,应为false - 当用户说「dsh 里看不到 MCP 工具」→ 查
env是否显式声明,以及是否误指向了启动器而非mcp-server.cjs - 当用户说「存进去的都是模型自言自语」→ 查
assistant/message的 coalesce 顺序,content[1].text必须先试 - 当用户说「帮我把这套装上」→ 按
quick_start逐步执行,每步跑对应的verification项,全绿再报完成