Bundle
dsh-chat-git
DeepSeek Harness 对话与代码管理增强插件:自动为每轮 AI Coding 对话创建 Git 检查点,按轮追踪改动,支持历史查看、对话分叉、编辑重发,以及对话和工作区独立回退。让你放心尝试每个想法,代码随时可回到任意版本,告别误改、丢稿和上下文混乱。零运行时依赖,安装即用。
- Source
- NOOB-P
- stars
- 1 stars
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-chat-git
[](#更新日志)
[](#许可证)
[](#前置要求)
[](#为什么零依赖无构建)
[](#运行测试)
> 把每个对话变成一条 **git 检查点链**的 [DeepSeek Harness](https://github.com/deepseek-ai) 插件。
每轮对话结束自动提交一次工作区,随时回到任意一轮的代码状态,或者只改对话不碰代码 —— 两条线互不干扰,而且每一步都能找回。
## DSH Desktop 插件市场
本插件通过 GitHub 仓库安装,安装后重启 DSH Desktop 即可加载:
```bash
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 状态、提交历史和变更,并按设置执行 `git init`、`git add`、`git commit` 以及用户确认的工作区还原。
- 将会话与检查点映射写入 `$DSH_HOME/chat-git.json`;不会把 Git 操作扩展到当前工作目录之外。
- 在 DSH Web 界面注入回退按钮、历史面板和设置项,通过本地 `/chat-git/*` 路由与宿主通信。
- 启用提交标题总结时,会把本轮提示词和暂存变更摘要交给当前 DSH 模型生成提交标题;关闭该设置即可不调用模型总结。
- 插件没有独立的远程服务,也没有运行时 npm 依赖;Git 是可选的系统级依赖。
<!-- 截图占位:把图片放进 docs/ 后取消下面的注释
<p align="center">
<img src="docs/history-pane.png" alt="历史标签页:左侧对话、右侧工作区 Git" width="900">
</p>
-->
---
## 目录
- [这是什么](#这是什么)
- [功能特性](#功能特性)
- [安装](#安装)
- [使用](#使用)
- [提交信息格式](#提交信息格式)
- [架构](#架构)
- [Host 路由](#host-路由)
- [状态文件](#状态文件)
- [设计边界与取舍](#设计边界与取舍)
- [开发](#开发)
- [常见问题](#常见问题)
- [更新日志](#更新日志)
- [许可证](#许可证)
---
## 这是什么
`dsh-chat-git` 是 DeepSeek Harness 的一个 profile bundle 插件。它把「对话」和「工作区代码」这两件本来各自漂移的事情绑成一条可回溯的链:
- 对话**开始**时,检查该会话的工作目录是不是一个 git 仓库,不是就 `git init`;
- 每轮对话**结束**(agent 交付完成)时,`git add -A -- .` + `git commit`,提交信息形如 `Ai-coding:修复设置页开关无法启用`;
- 提交描述默认由**模型总结**该轮的提示词与改动文件生成,而不是把冗长的原始提示词塞进标题;
- 回合图标行里多一个**回退**按钮:一下待确认、一下确认,同时回滚代码与对话;
- 标签栏里多一个 **历史** 标签页,左边管对话(打开新对话 / 回退 / 编辑并发送),右边管工作区 Git(查看提交、还原到任意提交),**两栏互不读写**。
整个插件**零 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. 取回该轮**完整的原始请求**(不是卡片上被截断的展示文本),文字与**图片**一起;
2. 重建对话,两条去向的差别只有一个 —— **你正站着的这条对话还留不留下**:
- **当前对话(还原)**:本窗口**变成**重建后的那条线(同样在「上一轮」处切开),**原对话随即归档**(可从归档恢复);
- **新建对话(fork)**:在它旁边另开一条只到「上一轮」的会话并切换过去,**原对话保持原样**,两条线都能继续,新线带可区分的标题(`… (1)`)。
3. 不论哪条去向,请求都被放进新会话的**输入框**,光标在那里等你 —— 想改就改,想换模型就用 shell 自己的模型选择器,不想发就删掉。
**它不会替你发送。** 之前的行为是截断后立刻把提示词排队,模型在用户还没看清之前就开始回答了;现在这一步停在输入区,发送键本身就是确认。
只有 **当前对话** 和 **回退到这里(还原)** 会归档原会话;**新建对话** 是纯粹的分叉。读取请求与移动会话都发生在**选完去向之后**,所以它们的失败报在这个对话框里,就报在做出选择的地方。
### 工作区 Git 还原(含当前位置)
- 点**还原到这里**会先弹出**确认对话框**,写明:要还原的提交 id 与主题、`HEAD 不动`、未跟踪文件不会被清理、以及 `当前位置:… → 还原后变成 …`;
- 确认前**不发任何请求**,`取消` 是真正的一等出口;
- 还原后,工作区当前所在的那一行会带上 **`当前位置`** 标签,按钮变成不可点的 `已在此位置`;列表上方另有一行「当前位置:xxxxxxx。」;
- 还原**只动工作区**:不改对话、不移动 HEAD、不丢检查点。
### 设置页
| 设置项 | 说明 |
| --- | --- |
| 自动检查点 | 总开关;未安装 git 时无法开启 |
| 自动保存间隔 | 每轮 / 每 2 轮 / … / 每 10 轮 |
| 历史标签页 | 纯展示偏好,关掉只是撤下这个标签页,功能不受影响 |
| 总结模型 | 关闭 / 使用当前模型 / 指定模型(provider + model) |
| 检测 | 执行 `git --version` |
| 下载 | 跳转 Git 官方下载页 |
---
## 安装
### 前置要求
- **Node.js ≥ 22**(插件本身零依赖,这是宿主的要求);
- **DeepSeek Harness**,并有一个可用的 profile(下面以 `web` 为例);
- **git**(可选但强烈建议;未安装时插件会检测到并给出下载入口,只是无法提交检查点)。
### 方式一:从 GitHub 直接安装(推荐)
```bash
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
```
pnpm 会把这个 GitHub 仓库拉进 profile 的依赖里,`dsh` 随后按已安装状态重建层列表 —— 不必先手动 `git clone`。
想改代码、随时 `git pull` 的话,先克隆再按本地路径安装:
```bash
git clone https://github.com/NOOB-P/dsh-chat-git.git
cd dsh-chat-git
dsh plugin --profile web add link:.
```
Windows 上用正斜杠或反斜杠都可以(在克隆出来的目录里执行):
```powershell
dsh plugin --profile web add link:.
```
### 方式二:下载 release 里的 tarball 安装
从 [Releases](https://github.com/NOOB-P/dsh-chat-git/releases/latest) 下载 `dsh-chat-git-<版本>.tgz`,然后:
```bash
dsh plugin --profile web add ./dsh-chat-git-0.6.0.tgz
```
或者一条命令直接从 release 资产安装(无需先下载):
```bash
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
```
也可以自己从源码打包再装:
```bash
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`。
### 更新
按当初的安装方式二选一:
```bash
# 装的是 github: 或 release tarball
dsh plugin --profile web add github:NOOB-P/dsh-chat-git
# 装的是本地克隆(link:)
git pull
dsh plugin --profile web add link:. # 重新链接
```
### 卸载
```bash
dsh plugin --profile web remove dsh-chat-git
```
> 装完 / 更新后需要**重启 `dsh web`** 才会加载。
---
## 使用
### 回合图标行的回退按钮
每轮 agent 回复下方的图标行里(**紧挨复制按钮之后**)多出一个回退图标:
1. **第一次点击** —— 进入待确认状态,按钮文案变化,此时不会发生任何事;
2. **第二次点击** —— 在该轮的结束序列处分叉出新会话并切换过去,原会话归档(可从归档恢复);
3. 移动鼠标或点别处即取消。
### 历史标签页
标签栏里(**对话** / **轨迹** 旁边)点 **历史**,得到左右两栏:
- **左栏**按时间**倒序**列出该会话的每一轮(最新一轮在最上面,带 `当前位置` 标签、带年月日时分秒时间戳,带图片的轮次另标 `含 N 张图片`),每张卡片三个动作:`从这里打开新对话(fork)` / `回退到这里(还原)` / `编辑并发送`;
- **右栏**展示工作区仓库的分支、HEAD、是否干净,以及最近 100 条提交(含不是本插件写的提交),每条可以 `还原到这里`。
正在进行中、还没有结束序列的那一轮无法分叉,按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork。
### 编辑并发送
1. 在左栏点某张卡片的 **编辑并发送**,在弹窗里选 **当前对话** 或 **新建对话**(`取消` 是真正的一等出口,点它不读请求、不建会话、不归档);
2. 会话切到重建后的新线,该轮的完整请求(文字 + 图片)已经在输入框里;
3. 改好之后按 shell 自己的发送键。
结果:该轮及其之后的所有对话不在新线上(实现上是在「上一轮」处分叉出新会话),而这一轮的请求被交还给**你**。选 **新建对话** 时原会话**不被归档**,每一轮都还在、随时切得回去,新线另外带一个可区分的标题(`… (1)`),所以两条线在侧栏里不会读成同一个对话;选 **当前对话** 时原会话归档,本窗口就是重建后的那条线(标题沿用不变 —— 它替换了自己的来源,而不是并排新增一条)。第 1 轮没有上一轮,两种去向都会在同一工作区**新建一个会话** —— 这才是「这一轮之前什么都不留」的诚实表达。
图片是**重新读进来**的:新线是在「这一轮之前」切开的,那张图的持久引用并不在新会话的日志里,所以字节是通过**原会话**自己的授权读回来、再注册成新会话的草稿图片。部署没挂 `conversation` 服务时,图片会被放弃,文字照常送达。
### 工作区 Git 栏
1. 点某条提交的 **还原到这里**;
2. 在确认对话框里核对提交主题、当前位置与还原后位置;
3. 点 **确认还原**。
工作区被 `git checkout <sha> -- .` 覆盖,并清掉该提交中不存在的新增路径;**HEAD 不动**,提交历史与对话都不变。未跟踪文件不属于任何检查点,删掉就找不回来,因此**不会被清理**。
### 设置项
`设置 → 仓库增强`。
---
## 提交信息格式
每个检查点的提交主题由固定的 `Ai-coding:` 前缀(全角冒号)加一段简短描述组成:
```
Ai-coding:修复设置页开关无法启用
```
- **描述优先由模型生成**:把该轮的提示词与 `git diff --cached` 摘要交给 `ctx.llm`,要求输出一句不超过 20 字、与提示词同语言的标题。返回结果会被清洗(去掉引号、`标题:` 之类的标签、被回显的 `Ai-coding:` 前缀、结尾句号),并截到 40 字。
- 整行(含前缀)**不超过 72 字符**,超出部分截断并以 `…` 结尾;
- 模型不可用、超时、报错或返回内容不可用时,**自动回退为提示词本身**(空白折叠成单个空格),绝不会因此丢掉检查点;
- 该轮没有记录到提示词时(会话在重启后恢复、或该轮没有用户消息),再回退为 `Ai-coding:第 N 轮对话`。
- 设置里的 **总结模型** 有三态:**关闭**(描述直接取提示词,零模型调用)、**使用当前模型**(该对话自身的模型,读不到时退回默认模型)、**指定模型**(显式选一个 provider + model)。
- 指定模型的选择器由 host 从**实时模型注册表**(`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` 的注册发生在启动期,而不是标签被点开的那一刻。
---
## Host 路由
全部 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 }` 映射:
```json
{
"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`),不会因为升级而悄悄把用户关掉的功能重新打开。
---
## 设计边界与取舍
这些是刻意的工程决定,不是未完成项:
1. **回退用 fork,不销毁会话。** DSH 没有「截断会话」API,只有 `sessions.fork({ sessionId, atSeq })`。所以「后面的对话全部删除」实现为:在该轮结束序列处分叉,得到只含前 N 轮的新会话并切换过去。原始日志保留,用户不会因为一次误点永久丢失记录。
2. **`git checkout` 语义是「还原内容 + 清掉新增」,HEAD 不动。** 单条 `git checkout <sha> -- .` 不会删除该提交中不存在的路径,后面几轮新建的文件会残留、让回溯看起来只做了一半。所以在此之后还会 `git rm` 掉 `sha..HEAD` 之间**新增**的路径。只动 HEAD 里存在的路径,因此每一步都还能从提交历史里找回。HEAD 保持不动,使「轮次 → 检查点」映射继续可读。
3. **未跟踪文件不碰。** 它们不属于任何检查点,删掉就找不回来了。回退后它们会作为未跟踪文件留在原地。
4. **已有仓库会被沿用,不会被重新 init。** 只有当会话工作目录**本身就是**一个仓库根时才会沿用;仅仅位于某个上层仓库**内部**时,会在工作目录里 `git init` 出自己的仓库,避免把提交打到用户没在这个对话里打开过的祖先仓库上。这个隔离由两件事共同保证:嵌套仓库本身,以及 `git add -A -- .` 中显式的 pathspec —— 自 Git 2.0 起,不带 pathspec 的 `git add -A` 会暂存**整个工作树**而与当前目录无关,一旦工作区落在更大的仓库内就会把无关改动一起卷进来。
5. **按钮落在回合图标行,而不是用户消息那一行。** 用户消息的复制按钮由 shell 的 `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, 分支按钮, …]`,即**紧邻复制按钮**。
6. **按钮靠 `messageId` 反查轮次。** `assistant-actions` 只派发 `messageId`,而检查点按轮次编号存放,回退还需要分叉边界。轮次号与回合结束序列都从 Chat snapshot 的 `turn-tail` 节点上读回(其已声明的载荷同时携带三者),选择器返回 `"turn:seq"` 字符串而非对象,以保证取值按值比较稳定、不引发多余渲染。
7. **`/chat-git/state` 的空 `sessionId` 是合法的全局读。** 设置页没有会话,只读开关、git 探测与状态文件路径。曾经把它当作缺参拒绝,结果设置页永远拿不到 state、开关一直禁用并显示 `sessionId is required` —— 现由 host 与 client 两侧的测试共同看住。
8. **git 走 `ctx.subprocess` 的 argv 数组**,不是 shell 字符串:没有引号规则要处理、提交信息与路径没有注入面,并且与工具调用享有同等的子进程生命周期与输出上限。
9. **AI 总结在读不到模型时必须是「少一个功能」而不是「丢一个检查点」。** 所以 `ctx.get('llm')` 与 `agentDefaultModel.currentSelection()` 都是可选读,整条路径返回空字符串即回退到提示词;`llm` 调用另有 8 秒上限,且 `GenerateOptions` 里不声明 `purpose` —— 本插件的调用不是 harness 的 `session-title` 功能,冒用它会让遥测把用量记到别处。标题消息以 `source: { kind: 'plugin', plugin: 'chat-git' }` 标注作者。
10. **标题调用发生在暂存之后、提交之前。** 先把 `git add -A -- .` 做掉,标题调用才能看到这一轮真正改了哪些文件 —— 这是「总结这一轮做了什么」而非「复述请求」的关键。代价是每轮结束会等一次模型调用(上限 8 秒);换来的是提交必定早于客户端渲染该轮的回退按钮,两者不会赛跑。
11. **偏好校验放在 store,不放在路由。** `setSummary` 自己拒绝未知的 `mode`,以及缺少 provider 或 model 的 `custom` —— 这样每个写入方都被覆盖,而路由只是转发。另一面是**部分补丁会合并**:只发 `{ mode: 'custom' }` 会保留已存好的路由,因此「切模式」和「选路由」可以分两步走,不会互相清空。
12. **选择器只提供可用的东西,但保留已成事实的配置。** 没有已注册模型的服务商不出现在列表里(选它必然被 host 拒绝,等于设陷阱);而**已存储**、注册表却不再列出的 provider / model 仍然可选 —— 否则打开一次设置页就会让用户的配置变得不可达。模型目录读取带 60 秒缓存,且每个服务商各自 4 秒上限,卡住的服务商只会退化成空模型列表,不会拖住整个设置页。
13. **历史标签页的数据来自会话日志,不来自客户端已渲染的内容。** `Session.snapshotEvents()` 的日志里 `turn/start` / `user/message` / `turn/end` 齐备,而 `turn/end` 事件自身的 `seq` 就是 fork 边界。这样读是**权威**的:不取决于聊天视图当前渲染了哪几轮,所以早已滚出窗口、甚至本次进程重启前发生的那一轮,同样拿得到可用的边界;代价是读取只对**已加载**的会话有效,未加载时如实报 `session-unavailable`,而不是猜一个边界。
14. **fork 与回退在 DSH 里是同一个原语,区别在原会话的去向。** 没有「截断会话」API,两者都只能 `sessions.fork({ atSeq })` 出一个到该轮为止的新会话,并在切换过去之前把剩余检查点 `inherit` 给新会话。区别是 `回退` 会额外把原会话**归档**(可恢复),让列表反映你实际走上的那条线;`fork` 则原样保留,两条线都能继续。
15. **没有结束序列的一轮不允许分叉。** 仍在进行中的轮次(或日志被截断)拿不到 `turn/end`,此时按钮置灰并说明原因 —— 猜一个边界会产出错误的 fork,比不给按钮更糟。
16. **对话与仓库分成两栏、两条路由,互不读写。** 左栏只发 `/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=…` 这类字符串当选项传下去。
17. **自动保存间隔只节流 git,不节流对话。** 每轮对话都由 harness 自己的会话日志逐轮记录,所以把间隔设成 2 或 3 只会让工作区的改动**攒着**,到下一个整数倍轮次一次性提交 —— 对话历史不会因此变稀疏,中间的改动也不会丢,它们就留在工作区里(右栏如实显示为未提交改动)。取值在 store 里校验为 1–10 的整数:0 或越界值会被**拒绝**而不是存下来,否则「每轮提交」的开关还亮着,实际上却再也不会提交。
18. **工作区根目录可以从活着的会话头解析。** 只信 store 里记的 `cwd` 是一个真实的 bug:插件加载时就已经打开的会话(或 harness 重启后从磁盘恢复的会话)在某一轮结束前没有记录,于是右栏对眼前明明存在的仓库回一句「没有记录到工作区」。因此 store 是快路径、会话的 `header.cwd` 是权威兜底,解析结果顺手记下来。`cwd` 仍然**不来自请求**,所以路由依旧无法被指向任意目录。
19. **提交行按 `git log` 的样子排版:图槽 + id + 主题 + 作者 · 时间。** 右栏列的是仓库的真实历史(含非本插件写的提交),所以作者必须显示。图槽只用 CSS 画一条竖线加一个圆点 —— 这份数据是线性历史,画真正的泳道会暗示并不存在的分支结构。
20. **失败要说清楚是谁失败,并且要能重试。** 右栏第一次上线时的报错是「读取仓库失败」,可真正的原因既不是仓库也不是 git:`/chat-git/repo` 这条路由根本没挂上(宿主进程还是旧构建),网页服务器自己回的是 `{ error: 'not found' }`,而客户端只读了 `error.message` —— 字符串没有 `.message`,于是拿到 `undefined`、落回那句泛泛的兜底文案,把唯一的线索丢了。现在错误信封先归一化成 `{ code, message }`,右栏显示宿主原话并给出**重试**按钮;同时**不再**在报错时同时显示「没有可读取的仓库」——把路由问题说成仓库问题,正好是误导用户的那个诊断。
21. **「编辑并发送」需要整段请求,所以它有自己的路由。** `/chat-git/timeline` 是给卡片用的,每段提示词都被截到 300 字;把这段**展示用的截断文本**交给新会话,等于悄悄让用户对着一个自己从没写过的问题继续。因此 `turnPrompt()` 与 `buildTimeline()` 分开:前者返回折叠出来的**原文与图片句柄**,后者只负责截断。这个读取发生在**会话被移动之前**,所以「读不到」报在用户按下按钮的地方,而且什么都不用回滚。
22. **重建仍是一次 fork,只是边界落在「上一轮」。** 选第 N 轮时,截断点必须是第 N-1 轮的结束序列 —— 第 N 轮及其之后才是要消失的部分。第 1 轮没有上一轮,`fork` 在「没有边界」时会保留整段对话,所以那一档改为在同一工作区 `sessions.create()` 出一个全新会话,这才是「这一轮之前什么都不留」的诚实表达。
23. **它不再替你发送。** 旧行为是「截断 → 立刻排队发送 → 归档原会话」,模型在用户还没看清新会话长什么样之前就开始回答了;而且发送失败时对话已经搬走,只能事后报错。现在顺序是「重建 → 把请求寄存到新会话 id → 切换」,发送键留给用户,`会话已重建但发送失败` 这个状态根本不存在了。它也**不归档原会话**:归档加上「分叉出来的新线继承同一个标题」,会让侧栏里刚点过的那条对话看起来像被回退到了上一轮 —— 而这是分叉,原对话每一轮都该还在。
24. **转交靠 `conversation.input.dock` 上的一个无渲染席位。** 新会话的输入区属于另一个插件(`ui-conversation`),而且它此刻**还不存在** —— `sessions.open` 是请求,不是同步切换。所以请求按 session id 寄存在插件自己的 `Map` 里,由注册在输入区 slot 上的 `ComposerSeeder` 在**它自己那一轮的输入区挂载时**取走,并只通过 shell 公布的 `inputActions.setDraft` / `addImages` 写入 —— 绝不去碰另一个插件的内部状态。取走即删除,否则用户中途切走再切回来,第二次挂载会把人家刚写的内容覆盖掉。
25. **`cwd` 只来自宿主自己知道的两个来源。** 见第 18 条:store 的记账与会话头的 `header.cwd`,二者都没有就如实拒绝(`session-unknown`),绝不从请求体里取路径,否则这条路由就成了任意目录读取器。
26. **还原的确认从「按钮点两下」改成对话框。** 一次 `git checkout` 会覆盖整个工作区,而确认它需要的是一句话(「你正在离开哪个提交、要去哪个提交、什么会被留下」),列表行里放不下这句话 —— 按钮变文字只是让人再点一次同一个按钮,等于把确认的内容省掉了。所以行上的按钮只负责**打开对话框**(此时不发任何请求),对话框里写明提交主题、HEAD 不动、未跟踪文件不清理,以及**当前位置 → 还原后位置**,确认才发 `/chat-git/restore`。副作用是「取消」变成了真正的一等出口。
27. **「当前位置」是记在 store 里的,不是从 git 读的。** 还原刻意不动 HEAD(第 2 条),所以还原之后 `git rev-parse HEAD` 答的已经不是「工作区站在哪儿」。位置因此记在 `sessionId -> position`:每次提交把它移到新提交,每次还原把它移到被还原的提交。同时返回 `positionKnown`,把「宿主有记录」和「宿主没有记录、这里只是 HEAD」分开 —— 右栏对这两种情况用不同措辞,而不是把推断说成事实。落到界面上就是三处:行首的 `当前位置` 标签、列表上方的「当前位置:…」一行、以及该行按钮变成不可点的 `已在此位置`(还原到自己已经站着的地方是个只会报成功的空操作)。
28. **历史左栏倒序,但序号是原始序号。** 左栏与右栏(`git log`)方向保持一致:最新一轮在最上面。倒序只是**渲染顺序** —— 每一项都带着它在时间线里的真实下标,因为「编辑并发送」需要的分叉边界属于**上一轮**(第 N-1 轮),而不是列表里的下一行。把倒序做进数据层会让那个边界指向错误的一轮。
29. **压缩检查点不是用户提示词。** 上下文压缩会把一段历史替换成一条合成的 `user/message`,它同样落在 `turn/start` 与 `turn/end` 之间。若不区分,卡片会显示「This is an automatically generated checkpoint …」这类前言,而「编辑并发送」会把它当成用户请求交回去。因此按**来源标记**(`source.kind === 'plugin' && source.plugin === 'compact'`)跳过,而不是按文案匹配:标记是协议,文案随时会变。标记写死在插件里而不是 import,是因为本插件零依赖 —— 标记若变,代价只是退回旧行为,绝不会崩。
30. **本插件不再写「默认模型」。** 旧版本为了让重建的新线跑在选定的模型上,先调 `POST /chat-git/set-model` 写入部署的默认选择 —— 那是一个**全局副作用**:一个历史卡片上的动作会改掉整个部署的模型,而模型选择器本身对这件事毫不知情。既然现在不再自动发送,这个时机就消失了:请求落进输入区,用户按 shell 自己的模型选择器选好再发,插件没有理由替他做这个决定。因此这条路由被**整个删掉**,对应的 host 测试改成断言它已经不存在 —— 留着一个没人调用的全局写入口,比删掉更危险。
31. **图片按原会话的授权读回来。** 新线是在「选中的那一轮之前」切开的,那张图的持久引用并不在新会话的日志里,所以它无法授权自己的字节。转交时用**原会话**的 `readAttachment` 把字节读成浏览器文件,再经 `conversation.createDraftImages` 铸成新会话的草稿图片 id(`addImages` 只认这种 id)。读不回来就放弃图片、保留文字:一个少了图片的请求仍然比一个根本交不过去的请求有用。
---
## 开发
### 运行测试
```bash
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),所以测试桩把子进程输出重定向到**普通文件描述符**再读取。
### 打包
```bash
npm pack --cache .npm-cache # Windows 沙箱下需要自定义 cache 目录
```
产出 `dsh-chat-git-<version>.tgz`,内含 `lib/`、`test/`、`cordis.patch.yml`、`README.md` 与 `package.json`。
### 代码约定
- 注释用英文,并且解释**为什么**,而不是复述代码在做什么;
- 所有面向用户的文案是中文;
- 提交前 `npm test` 必须全绿。
---
## 常见问题
<details>
<summary><b>为什么还原之后 HEAD 没动?</b></summary>
因为 HEAD 是「轮次 → 检查点」映射的锚点。如果还原顺手移动了 HEAD,后面几轮的检查点就会从历史上消失,右栏也就再也列不出它们了。所以还原只覆盖工作区内容并清掉新增路径,HEAD 保持不动。
代价是 git 自己答不出「工作区现在站在哪儿」,这正是插件把 `position` 记在状态文件里、并在列表上标出 `当前位置` 的原因。
</details>
<details>
<summary><b>为什么未跟踪文件没被删掉?</b></summary>
未跟踪文件不属于任何检查点 —— 它们从未被提交过,所以「回到某个提交的状态」并不能推出它们该不该存在。删掉就找不回来了,因此插件选择不碰它们,只还原 HEAD 里存在的路径。
</details>
<details>
<summary><b>为什么按钮在回合图标行,而不是用户消息旁边?</b></summary>
因为用户消息那一行**没有任何 slot**。shell 的 `UserMessageNodeView` 内联渲染复制按钮,不派发子席;相关的气泡组件是 `dsh-client-ui-chat` 的模块私有符号。回合图标行里的 `extraActions` 是唯一可以插入动作、并且渲染顺序紧邻复制按钮的位置。详见[设计边界第 5 条](#设计边界与取舍)。
</details>
<details>
<summary><b>回退之后原来的对话去哪了?</b></summary>
归档了,不是删除了。`回退到这里(还原)` 会新建一条只到该轮为止的会话并把原会话归档,可以从归档里恢复;`从这里打开新对话(fork)` 则原样保留两条线。两者在 DSH 里都是同一个 `sessions.fork` 原语,区别只在原会话的去向。`编辑并发送` 的弹窗把这件事变成了一道选择题:选 **当前对话** 就是归档(本窗口变成重建后的线),选 **新建对话** 就是保留。
</details>
<details>
<summary><b>一定要装 git 吗?</b></summary>
不装也能用,但没有任何检查点功能:插件检测不到 git 时会把自动检查点开关锁住,并在设置页给出官方下载入口。git 是唯一的外部依赖,而且是进程级的,不是 npm 依赖。
</details>
<details>
<summary><b>harness 重启后历史还能回退吗?</b></summary>
能。`sessionId -> { cwd, position, commits }` 映射写在 `$DSH_HOME/chat-git.json` 里,不在内存里。只要磁盘可写(写失败会降级为仅内存,不会报错),重启后旧对话的回退按钮依然可用。
但 `/chat-git/timeline` 只对**已加载**的会话有效:会话没加载时如实返回 `session-unavailable`,而不是猜一个边界。
</details>
<details>
<summary><b>「编辑并发送」为什么不用卡片上显示的提示词?</b></summary>
因为卡片上那段是**展示用的截断文本**(截到 300 字)。用它交回去等于悄悄让用户对着一个自己从没写过的问题继续。所以插件单开了一条 `/chat-git/turn-prompt` 返回完整原文(连图片一起),输入区用原文填充。
</details>
<details>
<summary><b>为什么点完「编辑并发送」不会自动发出去?</b></summary>
因为「按下这个按钮」表达的是「我想改这一轮」,不是「照原样再问一遍」。旧行为会在切换会话的同时排队发送,模型在用户还没看清之前就开始回答;现在它只把请求放进新会话的输入区,改不改、发不发都由用户决定,模型选择器也回到 shell 自己那一个。
</details>
<details>
<summary><b>图片也一起带过去了吗?</b></summary>
带了。新线是在选中那一轮**之前**切开的,所以那张图的持久引用不在新会话日志里,它授权不了自己的字节 —— 转交时用**原会话**的 `readAttachment` 把字节读成浏览器文件,再铸成新会话的草稿图片。部署没挂 `conversation` 服务时图片会被放弃,文字照常送达。
</details>
<details>
<summary><b>会自动提交别人的仓库吗?</b></summary>
不会。只有当会话工作目录**本身就是**仓库根时才沿用已有仓库;仅仅位于某个上层仓库内部时,会在工作目录里 `git init` 出自己的仓库。同时所有 git 命令都带显式 pathspec,不会波及工作区之外。
</details>
---
## 更新日志
### 0.6.0
- **「编辑并重新发送」改为「编辑并发送」,不再自动发送**:点下去只重建会话并把该轮请求放进**新会话的输入区**,改不改、发不发由你决定(旧行为会在切换会话的同时排队发送,模型在用户还没看清之前就开始回答)。发送键回到 shell 自己那一个,模型选择也回到 shell 自己的选择器。
- **图片一起带过去**:`/chat-git/turn-prompt` 现在返回 `{ turn, text, images }`,`images` 只带 `{ attachmentId }` 句柄;浏览器半边用**原会话**的 `readAttachment` 把字节读回来,再铸成新会话的草稿图片,与文字一起写进输入区。新线是在「这一轮之前」切开的,所以那些引用不在它的日志里,只有原会话能授权这些字节。
- **新增无渲染的转交席位** `conversation.input.dock`:新会话的输入区此刻还不存在(`sessions.open` 是请求不是同步切换),请求因此按 session id 寄存,由该席位在**它自己那一轮的输入区挂载时**取走 —— 取走即删除,中途切走再切回不会覆盖用户已经打好的内容。
- **删掉 `POST /chat-git/set-model`**:它是为了让重建的新线跑在选定模型上而写的**全局默认模型**,一个历史卡片上的动作会改掉整个部署的模型。既然不再自动发送,这个时机消失了,路由与对话框里的模型下拉框一并移除;host 测试改为断言这条路由**已经不存在**,而不是留着没人调用的全局写入口。
- **第 1 轮重建的会话留在原工作区分组里**:以前只传 `cwd`,而 `cwd` 只决定运行目录、不进分组(host 侧只有 `{ workspaceId }` 会走 `attachSession`),所以点第 1 轮的 **编辑并发送** 会在侧栏多出一个「未分组」条目,即使那个目录就是工作区本身。现在先按源会话反查它所属的 `workspaceId` 再创建,新会话与原会话同组;只有拿不到工作区服务时才退回 `{ cwd }`(两者在协议上互斥)。
- **历史时间戳显示年月日 + 时分秒**(`2026-09-13 14:05:32`):只写 `14:05` 答不出「这是今天、昨天还是上周」,而两个回合落在同一分钟里是常事。
- **「编辑并发送」改为先弹窗选去向**:点下去不再直接动作,而是弹出 **当前对话 / 新建对话 / 取消** 三个按钮 —— **当前对话(还原)** 在本窗口里回退到上一轮并从这一轮重新开始(原对话随即归档,可从归档恢复),**新建对话(fork)** 另开一条只到上一轮的会话并切换过去(原对话保持原样,两条线都能继续),**取消** 什么都不做。两者的唯一区别就是**你正站着的这条对话还留不留下**,而这件事在动作发生之后无法从界面上读出来,所以它必须在发生之前被问一次。读取整段请求与会话移动都发生在**选完去向之后**,失败因此报在这个对话框里。
- **卡片按钮改名**:`从这里 fork` → **`从这里打开新对话(fork)`**,`回退到这里` → **`回退到这里(还原)`**。原来的写法把英文动词丢给用户猜,而「回退」在 DSH 里到底是删掉还是归档,只有括号里那个词说得清。
- **「编辑并发送」不再归档原会话,且给新线一个可区分的标题**:以前切换过去之后会把原会话归档,而 fork 出来的新线继承同一个标题 —— 两件事叠在一起,侧栏里刚点过的那条对话看起来就像被「回退到了上一轮」,用户回头找不到自己原来的对话。现在 **新建对话** 是纯粹的分叉:原会话每一轮都在、原样留在侧栏,新线通过 `increaseTitle` 拿到 `… (1)` 这样的标题;**当前对话** 与 `回退到这里(还原)` 是仅有的两个会归档的动作。
- **卡片记住这一轮带过几张图片**:时间线现在返回 `imageCount`,带图片的卡片多一行 **`含 N 张图片`**。只写数量、不铺缩略图 —— 历史列表是列表而不是图库;句柄本身由唯一需要搬动它们的那一步(`编辑并发送`)去原会话重读。
- 测试 421 项(host 203 / client 218)。
### 0.5.0
- **历史左栏改为倒序**:最新一轮排在最上面,与右栏的 `git log` 方向一致;每一项仍带着它在时间线里的原始下标,所以「编辑并重新发送」需要的「上一轮」边界不受影响。
- **当前轮带 `当前位置` 标签**:最新一轮的卡片带药丸标签,列表上方另有一行「当前位置:第 N 轮。」。
- **修复压缩检查点被当成用户提示词**:上下文压缩会用一条 `user/message`(`source.plugin === 'compact'`)替换被压缩的片段,折叠时间线时它曾被当成该轮的提示词显示在卡片上,并会被「编辑并重新发送」原样发给模型。现在按**来源标记**跳过,卡片与编辑器都只认真实输入。
- 测试 421 项(host 205 / client 216)。
### 0.4.9
- **工作区 Git 还原改为弹窗确认**:行上的按钮只打开对话框(此时不发请求),对话框写明提交主题、HEAD 不动、未跟踪文件不清理,以及 `当前位置 → 还原后位置`;`取消` 成为一等出口。
- **新增「当前位置」标签**:当前所在提交的那一行带 `当前位置` 药丸标签、按钮变为不可点的 `已在此位置`,列表上方另有一行「当前位置:…」。位置记在 store 的 `position` 字段里,`/chat-git/repo` 同时返回 `positionKnown`。
- **「编辑并重新发送」支持重选模型**:对话框内新增扁平化模型下拉框,默认选中当前路由;新增 `POST /chat-git/set-model` 在会话重建**之前**写入默认选择,并对照实时模型目录校验。
### 0.4.8
- 历史卡片新增 **编辑并重新发送**:载入该轮完整原始提示词,确认后删掉该轮及其之后的所有对话并重发一次,原会话归档可恢复。
- 新增 `POST /chat-git/turn-prompt`,返回不截断的整段提示词。
> 更早版本的变更见提交历史。
---
## 许可证
[MIT](LICENSE) © NOOB-P
Install
dsh plugin --profile web add github:NOOB-P/dsh-chat-git#a69e004105d911cf13f1a2a42f3df0f4b38bed36
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-chat-git from the hub