deepseek-harness
deepseek-ai
DeepSeek Harness: Everything is a Plugin.
PROJECT TOPICS
PROJECT README
把每个对话变成一条 git 检查点链的 DeepSeek Harness 插件。
每轮对话结束自动提交一次工作区,随时回到任意一轮的代码状态,或者只改对话不碰代码 —— 两条线互不干扰,而且每一步都能找回。
本插件通过 GitHub 仓库安装,安装后重启 DSH Desktop 即可加载:
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
插件市场元信息位于 package.json 的 dsh 字段,profile bundle manifest 为
cordis.patch.yml。仓库使用 MIT 协议,并通过 GitHub 的 dsh-plugin Topic
参与社区插件索引。
git init、git add、git commit 以及用户确认的工作区还原。$DSH_HOME/chat-git.json;不会把 Git 操作扩展到当前工作目录之外。/chat-git/* 路由与宿主通信。dsh-chat-git 是 DeepSeek Harness 的一个 profile bundle 插件。它把「对话」和「工作区代码」这两件本来各自漂移的事情绑成一条可回溯的链:
git init;git add -A -- . + git commit,提交信息形如 Ai-coding:修复设置页开关无法启用;整个插件零 npm 依赖、无构建步骤,lib/ 下的文件就是源码。
| 能力 | 说明 |
|---|---|
自动 git init |
工作目录不是仓库根时自动初始化,不会把提交打到上层仓库去 |
| 每轮自动提交 | 默认每轮一次,可在设置里放宽到每 2 / 3 … 10 轮 |
| 模型生成标题 | 读提示词 + git diff --cached 摘要,输出一句 ≤20 字标题 |
| 失败必留检查点 | 模型不可用 / 超时 / 报错一律回退成提示词本身,绝不丢提交 |
回合图标行(复制按钮旁边)的回退按钮:点一下进入待确认,再点一下才真的回滚。回滚走 sessions.fork,所以原始会话不会被销毁,随时可以找回。
| 左栏 · 对话 | 右栏 · 工作区 Git |
|---|---|
| 按时间倒序列出每一轮(最新在最上) | 直接读仓库:分支 / HEAD / 是否干净 |
| 从这里打开新对话(fork) —— 新开一条线,原会话保留 | 列出最近 100 条提交(含非本插件写的) |
| 回退到这里(还原) —— 新开一条线,原会话归档 | 每条提交可还原到这里 |
| 编辑并发送 —— 弹窗选「当前对话 / 新建对话」,把该轮请求(文字 + 图片)放进输入框 | 标出当前位置并弹出确认对话框 |
每张卡片都带年月日 + 时分秒的时间戳(2026-09-13 14:05:32)—— 只写 14:05 答不出「这是今天、昨天还是上周」,而两个回合落在同一分钟里是常事。
带图片的那一轮会额外标一行 含 N 张图片 —— 只写数量、不铺缩略图:历史列表是列表,不是图库;句柄本身由唯一需要搬动它们的那一步去重读。
左栏的最上面就是当前所在的那一轮,它带一个 当前位置 标签,列表上方另有一行「当前位置:第 N 轮。」—— 列表一旦滚动,「最上面那行」就不再是答案。
两栏是两条独立的路由、两次独立的读取:仓库坏掉不会带走对话列表,反之亦然。
点历史卡片上的编辑并发送,先弹出对话框选去向(当前对话 / 新建对话 / 取消):
… (1))。它不会替你发送。 之前的行为是截断后立刻把提示词排队,模型在用户还没看清之前就开始回答了;现在这一步停在输入区,发送键本身就是确认。
只有 当前对话 和 回退到这里(还原) 会归档原会话;新建对话 是纯粹的分叉。读取请求与移动会话都发生在选完去向之后,所以它们的失败报在这个对话框里,就报在做出选择的地方。
HEAD 不动、未跟踪文件不会被清理、以及 当前位置:… → 还原后变成 …;取消 是真正的一等出口;当前位置 标签,按钮变成不可点的 已在此位置;列表上方另有一行「当前位置:xxxxxxx。」;| 设置项 | 说明 |
|---|---|
| 自动检查点 | 总开关;未安装 git 时无法开启 |
| 自动保存间隔 | 每轮 / 每 2 轮 / … / 每 10 轮 |
| 历史标签页 | 纯展示偏好,关掉只是撤下这个标签页,功能不受影响 |
| 总结模型 | 关闭 / 使用当前模型 / 指定模型(provider + model) |
| 检测 | 执行 git --version |
| 下载 | 跳转 Git 官方下载页 |
web 为例);dsh plugin --profile web add github:NOOB-P/dsh-chat-git
pnpm 会把这个 GitHub 仓库拉进 profile 的依赖里,dsh 随后按已安装状态重建层列表 —— 不必先手动 git clone。
想改代码、随时 git pull 的话,先克隆再按本地路径安装:
git clone https://github.com/NOOB-P/dsh-chat-git.git
cd dsh-chat-git
dsh plugin --profile web add link:.
Windows 上用正斜杠或反斜杠都可以(在克隆出来的目录里执行):
dsh plugin --profile web add link:.
从 Releases 下载 dsh-chat-git-<版本>.tgz,然后:
dsh plugin --profile web add ./dsh-chat-git-0.6.0.tgz
或者一条命令直接从 release 资产安装(无需先下载):
dsh plugin --profile web add https://github.com/NOOB-P/dsh-chat-git/releases/download/v0.6.0/dsh-chat-git-0.6.0.tgz
也可以自己从源码打包再装:
npm pack # 产出 dsh-chat-git-0.6.0.tgz
dsh plugin --profile web add ./dsh-chat-git-0.6.0.tgz
dsh plugin --profile <name> ... 是一条 pnpm 转发器:它在 profile 目录里执行 pnpm,然后按已安装状态重建 dsh.profile.bundles 层列表 —— 任何能解析到「声明了 dsh.bundle 的包」的依赖都会自动加入层栈,本包正是这种情况,所以不必手改 profile 的 package.json。
按当初的安装方式二选一:
# 装的是 github: 或 release tarball
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
# 装的是本地克隆(link:)
git pull
dsh plugin --profile web add link:. # 重新链接
dsh plugin --profile web remove dsh-chat-git
装完 / 更新后需要重启
dsh web才会加载。
每轮 agent 回复下方的图标行里(紧挨复制按钮之后)多出一个回退图标:
标签栏里(对话 / 轨迹 旁边)点 历史,得到左右两栏:
当前位置 标签、带年月日时分秒时间戳,带图片的轮次另标 含 N 张图片),每张卡片三个动作:从这里打开新对话(fork) / 回退到这里(还原) / 编辑并发送;还原到这里。正在进行中、还没有结束序列的那一轮无法分叉,按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork。
取消 是真正的一等出口,点它不读请求、不建会话、不归档);结果:该轮及其之后的所有对话不在新线上(实现上是在「上一轮」处分叉出新会话),而这一轮的请求被交还给你。选 新建对话 时原会话不被归档,每一轮都还在、随时切得回去,新线另外带一个可区分的标题(… (1)),所以两条线在侧栏里不会读成同一个对话;选 当前对话 时原会话归档,本窗口就是重建后的那条线(标题沿用不变 —— 它替换了自己的来源,而不是并排新增一条)。第 1 轮没有上一轮,两种去向都会在同一工作区新建一个会话 —— 这才是「这一轮之前什么都不留」的诚实表达。
图片是重新读进来的:新线是在「这一轮之前」切开的,那张图的持久引用并不在新会话的日志里,所以字节是通过原会话自己的授权读回来、再注册成新会话的草稿图片。部署没挂 conversation 服务时,图片会被放弃,文字照常送达。
工作区被 git checkout <sha> -- . 覆盖,并清掉该提交中不存在的新增路径;HEAD 不动,提交历史与对话都不变。未跟踪文件不属于任何检查点,删掉就找不回来,因此不会被清理。
设置 → 仓库增强。
每个检查点的提交主题由固定的 Ai-coding: 前缀(全角冒号)加一段简短描述组成:
Ai-coding:修复设置页开关无法启用
git diff --cached 摘要交给 ctx.llm,要求输出一句不超过 20 字、与提示词同语言的标题。返回结果会被清洗(去掉引号、标题: 之类的标签、被回显的 Ai-coding: 前缀、结尾句号),并截到 40 字。… 结尾;Ai-coding:第 N 轮对话。llm.listProviders() + llm.listModels())填充,因此不会列出本部署无法调用的路由;没有已注册模型的服务商不会出现在列表里,而已存储但注册表不再列出的路由仍保持可选 —— 否则打开一次设置页就会让用户的配置变得不可达。| 半边 | 入口 | 职责 |
|---|---|---|
| Host | lib/index.js(exports ".") |
会话事件钩子、git 命令、/chat-git 路由、设置持久化 |
| Client | lib/client.js(exports "./client") |
回退按钮、历史标签页、设置页 |
.
├── lib/
│ ├── index.js # host 半边:事件钩子 + 路由 + 状态
│ ├── client.js # client 半边:四个客户端席位
│ ├── git.js # git 封装(全部走 argv 数组)
│ ├── store.js # $DSH_HOME/chat-git.json 的读写与校验
│ ├── summarize.js # 模型生成提交标题(含清洗与回退)
│ └── timeline.js # 会话日志折叠成「轮次」与「整轮请求」(文字 + 图片句柄)
├── test/
│ ├── harness.mjs # host 侧:真实 git + 真实 loopback HTTP
│ └── client.mjs # client 侧:React 替身 + 假 host
├── cordis.patch.yml
└── package.json
lib/ 下的文件就是源码,没有构建步骤 —— 客户端半边直接写在 window.__ModuleLoader__.load({ id, factory }) 包装格式里(这正是 client-modules 期望被服务的文件形状),因此不需要 TypeScript、tsdown 或任何 npm 依赖。
Host 半边只 import node: 内置模块和同目录兄弟模块:profile 安装会把包软链过去,Node 会从真实项目路径向上解析裸模块名,那里并不存在依赖树,所以保持零依赖是刻意的。
| Slot | 类型 | 用途 |
|---|---|---|
conversation.chat.assistant-actions |
list | 回退按钮。shell 把它渲染成该回合图标行的 extraActions,即紧跟在复制按钮之后 |
conversation.view |
list | 标签栏里的历史标签页,左右两栏:左栏是对话(每一轮带「从这里打开新对话(fork)」「回退到这里(还原)」「编辑并发送」,带图片的轮次另标 含 N 张图片),右栏是工作区 git(提交列表,每条带「还原到这里」并带 当前位置 标记,还原前弹出确认对话框) |
settings.section |
list | 设置页(自动检查点开关 / 自动保存间隔 / 历史标签页开关 / 总结模型三态 / 检测 / 下载) |
conversation.input.dock |
list | 无渲染的转交席位:编辑并发送 把该轮的请求寄存在新会话 id 下,这个席位在该会话的输入区挂载时把它取走,写进草稿。它什么都不画,只占一个空节点 |
四个席位都是纯增量的 list:不与他人争抢任何 cell,因此本插件不会遮蔽、也不会顶掉任何既有 UI。
历史视图用 conversation.view 而不是自绘的浮层,是因为标签栏是从 slot 本身投影出来的:一个组件无法隐藏自己那一页,唯一能撤下标签的办法是退出注册表。因此设置里的开关先把偏好写进 host,再镜像到本地 store,由 store 的订阅者注册或注销这一席 —— 标签与开关不可能各说各话。这也是为什么开关状态必须持久化到磁盘:conversation.view 的注册发生在启动期,而不是标签被点开的那一刻。
全部 loopback-only、POST、JSON 信封 { ok, value } / { ok, error }。
路由只接受 sessionId,从不接受路径,因此无法被指向任意目录。
| 路由 | 作用 |
|---|---|
POST /chat-git/state |
全部偏好(自动检查点 / 自动保存间隔 / 历史标签页)的状态、git 探测结果、该会话的检查点列表 |
POST /chat-git/detect |
执行 git --version |
POST /chat-git/set-enabled |
切换自动检查点;git 不可用时拒绝开启 |
POST /chat-git/set-history |
切换历史标签页;纯展示偏好,不做任何能力检查 |
POST /chat-git/set-interval |
设置自动保存间隔(1–10 的整数);越界、非整数一律拒绝,不会存成"永不提交" |
POST /chat-git/set-summary |
局部更新总结偏好(mode / provider / model);无路由的 custom 被拒绝 |
POST /chat-git/timeline |
折叠该会话的日志,返回按顺序的每一轮(含 fork 边界与对应检查点);提示词按展示需要截断 |
POST /chat-git/turn-prompt |
取某一轮完整的原始请求(不截断):{ turn, text, images },images 只带 { attachmentId } 句柄,字节由浏览器半边经原会话授权自行读回 |
POST /chat-git/repo |
直接读该会话工作区的仓库:root / 分支 / HEAD / 当前位置(position + positionKnown)/ 是否干净 / 最近 100 条提交(与轮次无关) |
POST /chat-git/models |
实时模型目录,供设置页的总结模型选择器使用(带 60 秒缓存与每服务商 4 秒上限) |
POST /chat-git/revert |
该回合图标按钮用:git checkout <sha> -- .、清理之后新增的路径,并丢掉该轮之后的检查点 |
POST /chat-git/restore |
右栏用:把工作区还原到仓库里任意一个提交,只动工作区,不动对话、不动 HEAD、不丢检查点 |
POST /chat-git/inherit |
回退分叉后,把剩余检查点交给新会话 |
写在 $DSH_HOME/chat-git.json(默认 ~/.dsh/chat-git.json),内容为偏好与
sessionId -> { cwd, position, commits } 映射:
{
"version": 1,
"enabled": true,
"history": true,
"interval": 1,
"summary": { "mode": "current", "provider": "", "model": "" },
"sessions": { "<sessionId>": { "cwd": "...", "position": "", "commits": [] } }
}
放在磁盘上而非内存里,因此 harness 重启后历史对话的回退按钮依然可用。任何读写失败都降级为仅内存,不会把插件拖垮。
position 是「工作区当前站在哪个提交上」。它必须记在这里而不是从 git 读回来:还原刻意不动 HEAD,所以还原之后 git 已经答不出这个问题了。旧状态文件没有这个字段,读作「不知道」,路由会把它解析成 HEAD 并同时给出 positionKnown: false,让右栏可以说清楚自己显示的是记录还是推断。
0.2 的布尔 summarize 字段会被读取并迁移为对应的 summary.mode(false → off,true → current),不会因为升级而悄悄把用户关掉的功能重新打开。
这些是刻意的工程决定,不是未完成项:
sessions.fork({ sessionId, atSeq })。所以「后面的对话全部删除」实现为:在该轮结束序列处分叉,得到只含前 N 轮的新会话并切换过去。原始日志保留,用户不会因为一次误点永久丢失记录。git checkout 语义是「还原内容 + 清掉新增」,HEAD 不动。 单条 git checkout <sha> -- . 不会删除该提交中不存在的路径,后面几轮新建的文件会残留、让回溯看起来只做了一半。所以在此之后还会 git rm 掉 sha..HEAD 之间新增的路径。只动 HEAD 里存在的路径,因此每一步都还能从提交历史里找回。HEAD 保持不动,使「轮次 → 检查点」映射继续可读。git init 出自己的仓库,避免把提交打到用户没在这个对话里打开过的祖先仓库上。这个隔离由两件事共同保证:嵌套仓库本身,以及 git add -A -- . 中显式的 pathspec —— 自 Git 2.0 起,不带 pathspec 的 git add -A 会暂存整个工作树而与当前目录无关,一旦工作区落在更大的仓库内就会把无关改动一起卷进来。UserMessageNodeView 内联渲染,内部不渲染任何 slot;conversation.chat.node 下只声明了 tool.call.toolview / assistant-actions / turnTail / commandview 四个子席,没有任何用户消息操作位。且 UserStyleBubble / MessageIconActions 是 dsh-client-ui-chat 的模块私有符号,exports 只暴露 4 个值;所有公开 client 模块(ui-chat 4 个、ui-conversation 13 个、ui-session 3 个)都不提供可复用的气泡组件,dsh-client-ui-primitives 在本机安装树里甚至不存在。因此「复制旁边」只能落在回合图标行 —— 那里恰好是 MessageIconActions 的 extraActions,渲染顺序为 [时间, 复制按钮, extraActions, 分支按钮, …],即紧邻复制按钮。messageId 反查轮次。 assistant-actions 只派发 messageId,而检查点按轮次编号存放,回退还需要分叉边界。轮次号与回合结束序列都从 Chat snapshot 的 turn-tail 节点上读回(其已声明的载荷同时携带三者),选择器返回 "turn:seq" 字符串而非对象,以保证取值按值比较稳定、不引发多余渲染。/chat-git/state 的空 sessionId 是合法的全局读。 设置页没有会话,只读开关、git 探测与状态文件路径。曾经把它当作缺参拒绝,结果设置页永远拿不到 state、开关一直禁用并显示 sessionId is required —— 现由 host 与 client 两侧的测试共同看住。ctx.subprocess 的 argv 数组,不是 shell 字符串:没有引号规则要处理、提交信息与路径没有注入面,并且与工具调用享有同等的子进程生命周期与输出上限。ctx.get('llm') 与 agentDefaultModel.currentSelection() 都是可选读,整条路径返回空字符串即回退到提示词;llm 调用另有 8 秒上限,且 GenerateOptions 里不声明 purpose —— 本插件的调用不是 harness 的 session-title 功能,冒用它会让遥测把用量记到别处。标题消息以 source: { kind: 'plugin', plugin: 'chat-git' } 标注作者。git add -A -- . 做掉,标题调用才能看到这一轮真正改了哪些文件 —— 这是「总结这一轮做了什么」而非「复述请求」的关键。代价是每轮结束会等一次模型调用(上限 8 秒);换来的是提交必定早于客户端渲染该轮的回退按钮,两者不会赛跑。setSummary 自己拒绝未知的 mode,以及缺少 provider 或 model 的 custom —— 这样每个写入方都被覆盖,而路由只是转发。另一面是部分补丁会合并:只发 { mode: 'custom' } 会保留已存好的路由,因此「切模式」和「选路由」可以分两步走,不会互相清空。Session.snapshotEvents() 的日志里 turn/start / user/message / turn/end 齐备,而 turn/end 事件自身的 seq 就是 fork 边界。这样读是权威的:不取决于聊天视图当前渲染了哪几轮,所以早已滚出窗口、甚至本次进程重启前发生的那一轮,同样拿得到可用的边界;代价是读取只对已加载的会话有效,未加载时如实报 session-unavailable,而不是猜一个边界。sessions.fork({ atSeq }) 出一个到该轮为止的新会话,并在切换过去之前把剩余检查点 inherit 给新会话。区别是 回退 会额外把原会话归档(可恢复),让列表反映你实际走上的那条线;fork 则原样保留,两条线都能继续。turn/end,此时按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork,比不给按钮更糟。/chat-git/timeline、只做 sessions.fork + 归档;右栏只发 /chat-git/repo 与 /chat-git/restore。这不只是排版:两侧的失败因此彼此隔离(仓库坏掉不会带走对话列表),而且 restore 刻意不复用 /chat-git/revert —— 后者会顺手丢掉该轮之后的检查点,那是「连对话一起回退」才需要的语义,右栏只动工作区,所以那一步必须不发生。代价是右栏能还原仓库里的任意提交(包括不是本插件创建的提交),因此 sha 在进入 argv 之前先按对象名形状校验,再经 rev-parse --verify <sha>^{commit} 解析成完整提交 id:既不做路径拼接,也不可能把 --upload-pack=… 这类字符串当选项传下去。cwd 是一个真实的 bug:插件加载时就已经打开的会话(或 harness 重启后从磁盘恢复的会话)在某一轮结束前没有记录,于是右栏对眼前明明存在的仓库回一句「没有记录到工作区」。因此 store 是快路径、会话的 header.cwd 是权威兜底,解析结果顺手记下来。cwd 仍然不来自请求,所以路由依旧无法被指向任意目录。git log 的样子排版:图槽 + id + 主题 + 作者 · 时间。 右栏列的是仓库的真实历史(含非本插件写的提交),所以作者必须显示。图槽只用 CSS 画一条竖线加一个圆点 —— 这份数据是线性历史,画真正的泳道会暗示并不存在的分支结构。/chat-git/repo 这条路由根本没挂上(宿主进程还是旧构建),网页服务器自己回的是 { error: 'not found' },而客户端只读了 error.message —— 字符串没有 .message,于是拿到 undefined、落回那句泛泛的兜底文案,把唯一的线索丢了。现在错误信封先归一化成 { code, message },右栏显示宿主原话并给出重试按钮;同时不再在报错时同时显示「没有可读取的仓库」——把路由问题说成仓库问题,正好是误导用户的那个诊断。/chat-git/timeline 是给卡片用的,每段提示词都被截到 300 字;把这段展示用的截断文本交给新会话,等于悄悄让用户对着一个自己从没写过的问题继续。因此 turnPrompt() 与 buildTimeline() 分开:前者返回折叠出来的原文与图片句柄,后者只负责截断。这个读取发生在会话被移动之前,所以「读不到」报在用户按下按钮的地方,而且什么都不用回滚。fork 在「没有边界」时会保留整段对话,所以那一档改为在同一工作区 sessions.create() 出一个全新会话,这才是「这一轮之前什么都不留」的诚实表达。会话已重建但发送失败 这个状态根本不存在了。它也不归档原会话:归档加上「分叉出来的新线继承同一个标题」,会让侧栏里刚点过的那条对话看起来像被回退到了上一轮 —— 而这是分叉,原对话每一轮都该还在。conversation.input.dock 上的一个无渲染席位。 新会话的输入区属于另一个插件(ui-conversation),而且它此刻还不存在 —— sessions.open 是请求,不是同步切换。所以请求按 session id 寄存在插件自己的 Map 里,由注册在输入区 slot 上的 ComposerSeeder 在它自己那一轮的输入区挂载时取走,并只通过 shell 公布的 inputActions.setDraft / addImages 写入 —— 绝不去碰另一个插件的内部状态。取走即删除,否则用户中途切走再切回来,第二次挂载会把人家刚写的内容覆盖掉。cwd 只来自宿主自己知道的两个来源。 见第 18 条:store 的记账与会话头的 header.cwd,二者都没有就如实拒绝(session-unknown),绝不从请求体里取路径,否则这条路由就成了任意目录读取器。git checkout 会覆盖整个工作区,而确认它需要的是一句话(「你正在离开哪个提交、要去哪个提交、什么会被留下」),列表行里放不下这句话 —— 按钮变文字只是让人再点一次同一个按钮,等于把确认的内容省掉了。所以行上的按钮只负责打开对话框(此时不发任何请求),对话框里写明提交主题、HEAD 不动、未跟踪文件不清理,以及当前位置 → 还原后位置,确认才发 /chat-git/restore。副作用是「取消」变成了真正的一等出口。git rev-parse HEAD 答的已经不是「工作区站在哪儿」。位置因此记在 sessionId -> position:每次提交把它移到新提交,每次还原把它移到被还原的提交。同时返回 positionKnown,把「宿主有记录」和「宿主没有记录、这里只是 HEAD」分开 —— 右栏对这两种情况用不同措辞,而不是把推断说成事实。落到界面上就是三处:行首的 当前位置 标签、列表上方的「当前位置:…」一行、以及该行按钮变成不可点的 已在此位置(还原到自己已经站着的地方是个只会报成功的空操作)。git log)方向保持一致:最新一轮在最上面。倒序只是渲染顺序 —— 每一项都带着它在时间线里的真实下标,因为「编辑并发送」需要的分叉边界属于上一轮(第 N-1 轮),而不是列表里的下一行。把倒序做进数据层会让那个边界指向错误的一轮。user/message,它同样落在 turn/start 与 turn/end 之间。若不区分,卡片会显示「This is an automatically generated checkpoint …」这类前言,而「编辑并发送」会把它当成用户请求交回去。因此按来源标记(source.kind === 'plugin' && source.plugin === 'compact')跳过,而不是按文案匹配:标记是协议,文案随时会变。标记写死在插件里而不是 import,是因为本插件零依赖 —— 标记若变,代价只是退回旧行为,绝不会崩。POST /chat-git/set-model 写入部署的默认选择 —— 那是一个全局副作用:一个历史卡片上的动作会改掉整个部署的模型,而模型选择器本身对这件事毫不知情。既然现在不再自动发送,这个时机就消失了:请求落进输入区,用户按 shell 自己的模型选择器选好再发,插件没有理由替他做这个决定。因此这条路由被整个删掉,对应的 host 测试改成断言它已经不存在 —— 留着一个没人调用的全局写入口,比删掉更危险。readAttachment 把字节读成浏览器文件,再经 conversation.createDraftImages 铸成新会话的草稿图片 id(addImages 只认这种 id)。读不回来就放弃图片、保留文字:一个少了图片的请求仍然比一个根本交不过去的请求有用。npm test # 两套一起跑
npm run test:host # 203 项:真实 git、假 ctx/假模型、日志折叠(含压缩检查点跳过)、整段请求(文字 + 图片句柄)读取、`set-model` 已下线、当前位置记账、间隔节流、工作区兜底、真实 loopback HTTP
npm run test:client # 218 项:席位注册(含无渲染的转交席位)、消息→轮次反查、回退流程、三态选择器、倒序两栏历史与当前轮标记、时间戳格式(年月日 + 时分秒、同一分钟可区分、无时间不显示 1970)、图片数量标记、还原确认对话框与当前位置标记、编辑并发送三选一对话框(当前对话归档 / 新建对话保留 / 取消)、重建会话 + 预填输入区 + 图片回读 + 只取一次、间隔选择、失败重试
test/harness.mjs 用真实 git 子进程在一个临时工作区里跑完整链路,并通过真实 HTTP 服务器驱动路由处理器,因此 loopback 守卫、JSON 信封都真正被覆盖。
test/client.mjs 用一个刻意很小的 React 替身(函数组件递归展开、useState 跨渲染保持)在 Node 里驱动客户端半边,不需要浏览器。
Windows 沙箱注意:受限模式下 Node 无法用
stdio: 'pipe'捕获子进程输出(spawn 直接 EPERM),所以测试桩把子进程输出重定向到普通文件描述符再读取。
npm pack --cache .npm-cache # Windows 沙箱下需要自定义 cache 目录
产出 dsh-chat-git-<version>.tgz,内含 lib/、test/、cordis.patch.yml、README.md 与 package.json。
npm test 必须全绿。因为 HEAD 是「轮次 → 检查点」映射的锚点。如果还原顺手移动了 HEAD,后面几轮的检查点就会从历史上消失,右栏也就再也列不出它们了。所以还原只覆盖工作区内容并清掉新增路径,HEAD 保持不动。
代价是 git 自己答不出「工作区现在站在哪儿」,这正是插件把 position 记在状态文件里、并在列表上标出 当前位置 的原因。
未跟踪文件不属于任何检查点 —— 它们从未被提交过,所以「回到某个提交的状态」并不能推出它们该不该存在。删掉就找不回来了,因此插件选择不碰它们,只还原 HEAD 里存在的路径。
因为用户消息那一行没有任何 slot。shell 的 UserMessageNodeView 内联渲染复制按钮,不派发子席;相关的气泡组件是 dsh-client-ui-chat 的模块私有符号。回合图标行里的 extraActions 是唯一可以插入动作、并且渲染顺序紧邻复制按钮的位置。详见设计边界第 5 条。
归档了,不是删除了。回退到这里(还原) 会新建一条只到该轮为止的会话并把原会话归档,可以从归档里恢复;从这里打开新对话(fork) 则原样保留两条线。两者在 DSH 里都是同一个 sessions.fork 原语,区别只在原会话的去向。编辑并发送 的弹窗把这件事变成了一道选择题:选 当前对话 就是归档(本窗口变成重建后的线),选 新建对话 就是保留。
不装也能用,但没有任何检查点功能:插件检测不到 git 时会把自动检查点开关锁住,并在设置页给出官方下载入口。git 是唯一的外部依赖,而且是进程级的,不是 npm 依赖。
能。sessionId -> { cwd, position, commits } 映射写在 $DSH_HOME/chat-git.json 里,不在内存里。只要磁盘可写(写失败会降级为仅内存,不会报错),重启后旧对话的回退按钮依然可用。
但 /chat-git/timeline 只对已加载的会话有效:会话没加载时如实返回 session-unavailable,而不是猜一个边界。
因为卡片上那段是展示用的截断文本(截到 300 字)。用它交回去等于悄悄让用户对着一个自己从没写过的问题继续。所以插件单开了一条 /chat-git/turn-prompt 返回完整原文(连图片一起),输入区用原文填充。
因为「按下这个按钮」表达的是「我想改这一轮」,不是「照原样再问一遍」。旧行为会在切换会话的同时排队发送,模型在用户还没看清之前就开始回答;现在它只把请求放进新会话的输入区,改不改、发不发都由用户决定,模型选择器也回到 shell 自己那一个。
带了。新线是在选中那一轮之前切开的,所以那张图的持久引用不在新会话日志里,它授权不了自己的字节 —— 转交时用原会话的 readAttachment 把字节读成浏览器文件,再铸成新会话的草稿图片。部署没挂 conversation 服务时图片会被放弃,文字照常送达。
不会。只有当会话工作目录本身就是仓库根时才沿用已有仓库;仅仅位于某个上层仓库内部时,会在工作目录里 git init 出自己的仓库。同时所有 git 命令都带显式 pathspec,不会波及工作区之外。
/chat-git/turn-prompt 现在返回 { turn, text, images },images 只带 { attachmentId } 句柄;浏览器半边用原会话的 readAttachment 把字节读回来,再铸成新会话的草稿图片,与文字一起写进输入区。新线是在「这一轮之前」切开的,所以那些引用不在它的日志里,只有原会话能授权这些字节。conversation.input.dock:新会话的输入区此刻还不存在(sessions.open 是请求不是同步切换),请求因此按 session id 寄存,由该席位在它自己那一轮的输入区挂载时取走 —— 取走即删除,中途切走再切回不会覆盖用户已经打好的内容。POST /chat-git/set-model:它是为了让重建的新线跑在选定模型上而写的全局默认模型,一个历史卡片上的动作会改掉整个部署的模型。既然不再自动发送,这个时机消失了,路由与对话框里的模型下拉框一并移除;host 测试改为断言这条路由已经不存在,而不是留着没人调用的全局写入口。cwd,而 cwd 只决定运行目录、不进分组(host 侧只有 { workspaceId } 会走 attachSession),所以点第 1 轮的 编辑并发送 会在侧栏多出一个「未分组」条目,即使那个目录就是工作区本身。现在先按源会话反查它所属的 workspaceId 再创建,新会话与原会话同组;只有拿不到工作区服务时才退回 { cwd }(两者在协议上互斥)。2026-09-13 14:05:32):只写 14:05 答不出「这是今天、昨天还是上周」,而两个回合落在同一分钟里是常事。从这里 fork → 从这里打开新对话(fork),回退到这里 → 回退到这里(还原)。原来的写法把英文动词丢给用户猜,而「回退」在 DSH 里到底是删掉还是归档,只有括号里那个词说得清。increaseTitle 拿到 … (1) 这样的标题;当前对话 与 回退到这里(还原) 是仅有的两个会归档的动作。imageCount,带图片的卡片多一行 含 N 张图片。只写数量、不铺缩略图 —— 历史列表是列表而不是图库;句柄本身由唯一需要搬动它们的那一步(编辑并发送)去原会话重读。git log 方向一致;每一项仍带着它在时间线里的原始下标,所以「编辑并重新发送」需要的「上一轮」边界不受影响。当前位置 标签:最新一轮的卡片带药丸标签,列表上方另有一行「当前位置:第 N 轮。」。user/message(source.plugin === 'compact')替换被压缩的片段,折叠时间线时它曾被当成该轮的提示词显示在卡片上,并会被「编辑并重新发送」原样发给模型。现在按来源标记跳过,卡片与编辑器都只认真实输入。当前位置 → 还原后位置;取消 成为一等出口。当前位置 药丸标签、按钮变为不可点的 已在此位置,列表上方另有一行「当前位置:…」。位置记在 store 的 position 字段里,/chat-git/repo 同时返回 positionKnown。POST /chat-git/set-model 在会话重建之前写入默认选择,并对照实时模型目录校验。POST /chat-git/turn-prompt,返回不截断的整段提示词。更早版本的变更见提交历史。
MIT © NOOB-P
CLASSIFICATION EVIDENCE
系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: 无有效分类标签。