dsh-any-agent 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +467 -0
- package/LICENSE +21 -0
- package/README.md +430 -2
- package/README.zh.md +448 -0
- package/USAGE-MIGRATION.md +260 -0
- package/USAGE-NOTES.en.md +134 -0
- package/USAGE-NOTES.md +275 -0
- package/bin/dsh-any-agent-workspaces.mjs +102 -0
- package/bin/dsh-any-agent.mjs +73 -0
- package/package.json +59 -4
- package/profile-example/cordis.patch.yml +110 -0
- package/src/acp.mjs +266 -0
- package/src/bootstrap.mjs +428 -0
- package/src/config.mjs +315 -0
- package/src/hub.mjs +1370 -0
- package/src/locks.mjs +311 -0
- package/src/mcp.mjs +181 -0
- package/src/titles.mjs +55 -0
- package/src/tools.mjs +623 -0
- package/src/workspace.mjs +418 -0
package/DESIGN.md
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# dsh-any-agent 功能设计(v0.2 草案)
|
|
2
|
+
|
|
3
|
+
> 目标:做一个 MCP Server,让 **Codex / Claude Code** 能调用 **DeepSeek Harness (DSH)** 当"外包工程师"——写代码、搭脚手架、跑任务,并且 DSH 自己还能挂它自己的 MCP。
|
|
4
|
+
>
|
|
5
|
+
> 本文只谈**功能与接口**,不写实现代码。所有技术判断都基于对本机 DSH 0.1.7-rc.2 的实测(见 §2 证据)。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 0. ⚠️ 架构修订(v0.2):后端改用 `acp` profile,不要用 `sdk`
|
|
10
|
+
|
|
11
|
+
初版把后端定成 `dsh --profile sdk`。**这是错的**——SDK 协议缺三样致命能力(不能恢复、不能中断、审批不可应答),而 **DSH 自带的另一个 profile 一次全给了**。
|
|
12
|
+
|
|
13
|
+
`dsh --profile acp` 的描述就是 *"Serve automation clients over Agent Client Protocol stdio"*——**它就是为程序化客户端准备的**,而且是同一套 `dsh-base` 能力面。实测它自报的能力:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},
|
|
17
|
+
"agentCapabilities":{"sessionCapabilities":{"close":{},"list":{},"resume":{}},
|
|
18
|
+
"mcpCapabilities":{"http":true},
|
|
19
|
+
"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},
|
|
20
|
+
"authMethods":[]}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**两个协议的能力对照(SDK 列为实测的"做不到",ACP 列为实测的"做得到"):**
|
|
24
|
+
|
|
25
|
+
| 能力 | `sdk` profile | `acp` profile |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **恢复会话** | ❌ 已存在 id 报 `already exists`,无 resume 方法 | ✅ **`session/resume` 跨进程实测通过**(杀掉创建者进程后在新进程恢复成功) |
|
|
28
|
+
| **中断回合** | ❌ 无 cancel,只能杀进程 | ✅ `session/cancel` → `AcpSession.cancel()` → `agent.cancel({kind:'user'})`(**和用户手动按停止是同一条路径**) |
|
|
29
|
+
| 会话列表 | ❌ 无 | ✅ `session/list`(注意:**只列"未打开"的**,见 §4.A2) |
|
|
30
|
+
| 关闭会话 | ❌ 无(只能关整个进程) | ✅ `session/close` |
|
|
31
|
+
| **审批应答** | ❌ 无通道,操作静默失败 | ✅ `session/request_permission` 客户端往返 → **中间档权限救回来了** |
|
|
32
|
+
| 灰/黑分流 | 同一 `assistant/message` 里靠 `type` 分 | ✅ **两个独立流通道**:`agent_thought_chunk`(灰)/ `agent_message_chunk`(黑) |
|
|
33
|
+
| 每会话 MCP 服务器 | ❌ 进程级配置 | ✅ `session/new` 与 `session/resume` 都接受 `mcpServers` 参数 |
|
|
34
|
+
| 模型/思考档 | 进程级 `initialize` | ✅ `session/setConfigOption`;**恢复时还会带回原会话的 `configOptions`** |
|
|
35
|
+
| 会话 id | 客户端指定 | 服务端分配(`session/new` 返回),恢复时原样传回 |
|
|
36
|
+
|
|
37
|
+
**唯一保留 SDK 协议理由的场景**:当你需要"自己指定会话 id"或"极简 3 方法"时。其余一律用 ACP。
|
|
38
|
+
|
|
39
|
+
传输层两者一样:**换行分隔 JSON-RPC over stdio**,且都绑定了 `stdin EOF → 有界退出`,所以生命周期管理代码可以共用。
|
|
40
|
+
|
|
41
|
+
**ACP 的三个坑(实测):**
|
|
42
|
+
1. `session/list` **排除本进程已打开的会话**(设计如此——它是给客户端"打开最近会话"用的)。所以"列出全部"必须两路合并:本进程持有的 + `list` 返回的。
|
|
43
|
+
2. `session/list` 只返回 `{sessionId, cwd}`,**不含标题**(ACP schema 允许 `title`,但 dsh-acp 只映射了 id 和 cwd)。对话名仍需从 `session/title` 事件或投影缓存取。
|
|
44
|
+
3. `session/resume` **校验 cwd**:与持久化记录不同目录会报 `invalidParams: session cwd does not match`。恢复时必须传原工作区。
|
|
45
|
+
4. 图片提示**不支持**(`promptCapabilities.image: false`),初版 §4.B8 里"contentBlocks 支持图片"这条对 ACP 不成立。
|
|
46
|
+
5. 好消息:ACP 的 resume **不走** headless 那个 `assertAdoptable` 门槛 ⇒ **web GUI 建的会话也能被恢复**(headless 做不到,这正是初版卡死的地方)。
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 1. 一句话结论
|
|
51
|
+
|
|
52
|
+
**不要**把 DSH 当成"被 CLI 调用的命令行工具"来包,而要把它当成**可长驻的 agent 运行时**来驱动:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Codex / Claude ──MCP(stdio)──▶ dsh-any-agent(我们写)
|
|
56
|
+
│ 换行分隔 JSON-RPC(ACP)
|
|
57
|
+
▼
|
|
58
|
+
dsh --profile acp ← 每个会话一个长驻进程
|
|
59
|
+
│
|
|
60
|
+
├─ 自己的工具(fs/pwsh/搜索/技能/子代理/workflow)
|
|
61
|
+
└─ dsh-mcp-client ─▶ 用户自己的 MCP 服务器
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
会话的"活着"体现在进程上,但**因为 ACP 支持 `session/resume`,进程不再是单点故障**:进程崩了/被回收/被杀,重建一个进程 resume 回来即可继续对话。这是 v0.2 相对初版最重要的修正。
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 2. 已核实的技术底座
|
|
69
|
+
|
|
70
|
+
> 本节主要记录 **SDK 协议**的实测证据,用于对照与排错。**实际实现请走 §0 的 ACP 路线**;ACP 的报文形状见 §0 与 §5。
|
|
71
|
+
|
|
72
|
+
### 2.1 后端进程与协议(`dsh --profile sdk`)
|
|
73
|
+
|
|
74
|
+
传输:**换行分隔 JSON-RPC over stdio**(每行一个 JSON 对象,无 LSP 式 Content-Length 头)。实测握手成功、`shutdown` 干净退出(exit code 0)。
|
|
75
|
+
|
|
76
|
+
**客户端 → 服务端(仅 3 个方法)**
|
|
77
|
+
|
|
78
|
+
| 方法 | 参数 | 返回 |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `initialize` | `{cwd, provider, model, reasoningEffort?, maxTokens?}` | `{serverInfo:{name:'deepseek-harness-sdk-runtime', version}}` |
|
|
81
|
+
| `session/prompt` | `{sessionId, contentBlocks:[...]}` | `{messageId}` |
|
|
82
|
+
| `shutdown` | — | `{}` |
|
|
83
|
+
|
|
84
|
+
**服务端 → 客户端(仅 4 个通知)**
|
|
85
|
+
|
|
86
|
+
| 通知 | 载荷 | 用途 |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `session.event` | `{sessionId, event}` | **完整会话事件流**:助手消息、工具调用/结果、回合与步骤边界、标题… |
|
|
89
|
+
| `session.status` | `{sessionId, status:'idle'\|'running'}` | **回合完成判定** |
|
|
90
|
+
| `subagent.started` | `{parentSessionId, childSessionId}` | 子代理派生 |
|
|
91
|
+
| `subagent.finished` | `{parentSessionId, childSessionId, status, stopReason, lastAssistantMessage?}` | 子代理收尾 |
|
|
92
|
+
|
|
93
|
+
关键性质:
|
|
94
|
+
- `session/prompt` 的 `sessionId` **未知时惰性创建 agent+会话对** → 会话 id 完全由我们掌控,可以直接用 DSH 原生格式 `session-<uuid>`,于是**磁盘上的会话目录、标题、transcript 全部对齐**,用户在自己的 DSH GUI 里也能看到这些对话。
|
|
95
|
+
- ⚠️ **但传一个"已存在于磁盘"的 id 会直接报错**(实测 `{"code":-32603,"message":"session \"X\" already exists"}`),因为服务端只调 `agents.create()`,从不调 `agents.resume()`。**⇒ 跨进程恢复会话在这条协议上不可能。** 同一进程内复用同一 id 可以继续对话;进程一死,那个会话就再也接不回来了。
|
|
96
|
+
- `initialize` 一次定全局 `cwd/provider/model/reasoningEffort/maxTokens` → 想换模型或工作目录就得换进程。
|
|
97
|
+
- 协议**没有**会话列表、没有标题读取、没有重命名、没有中断方法 → 这些都要我们自己补(见 §4)。
|
|
98
|
+
|
|
99
|
+
### 2.2 会话身份、标题与存储
|
|
100
|
+
|
|
101
|
+
| 项目 | 事实 |
|
|
102
|
+
|---|---|
|
|
103
|
+
| 存储根 | `%USERPROFILE%\.dsh\sessions\<项目key>\<会话id>\session.v4.jsonl.zstd` |
|
|
104
|
+
| 项目 key | 工作目录转义而来,如 `--D-AI_MCP-DSH_MCP--` |
|
|
105
|
+
| 物理格式 | **多帧拼接 zstd**,每帧是一批已压缩的 JSONL 记录(实测 295KB 文件 = 189 帧 / 1.04MB 明文 / 349 条记录) |
|
|
106
|
+
| 首帧 | `{"type":"session", version, id, createdAt, cwd, isSeeded, delegationDepth, agentPreset}` |
|
|
107
|
+
| **并行写锁** | `SessionWriteLease`:POSIX `flock` / **Windows 命名内核信号量**,持有者活着就**永不过期**,争用抛 `SessionAlreadyOwnedError`。**读内容/搜索/列目录不受影响**;但注意 ★**实测**:在 GUI 里"打开"一个会话这个动作本身就要为可写取句柄 → **会占锁**(见 B10)。 |
|
|
108
|
+
| 投影缓存 | `.dsh\storages\session_projcache\sessions\<id>.json` → `record.rows.title.val`(标题)、`sandboxMode`、`tokenUsage`、`contextPressure`;每 200 事件或 5 秒写一次 |
|
|
109
|
+
| 工作区注册表 | `.dsh\storages\workspace.json` → `workspaces[id] = {path, title, sessionIds, createdAt, updatedAt}` + `pinnedSessionIds` / `archivedSessionIds` |
|
|
110
|
+
|
|
111
|
+
**对话标题(用户明确要的功能)已经有两条原生来源:**
|
|
112
|
+
|
|
113
|
+
1. **会话日志里的 `session/title` 事件**:`{title, messageSeqs, source:{kind:'fallback'|'llm'}}`
|
|
114
|
+
- `fallback` = 截取首条用户消息(`session-title` 插件配置:`fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80`)
|
|
115
|
+
- `llm` = 由 `session-title-first-prompt-llm` 生成(`targetWords: 5, targetCjkCharacters: 10, maxOutputTokens: 64`,系统提示要求"用消息的语言、约 5 词 / 10 个汉字、纯文本")。
|
|
116
|
+
⚠️ 该插件在 **sdk profile 里是 `disabled: true`**,所以走 SDK 起的会话默认只有 fallback 标题。我们的专用 profile 应当把它打开。
|
|
117
|
+
2. **投影缓存 `record.rows.title.val`**(读历史会话时用,读者不加锁,安全)。
|
|
118
|
+
|
|
119
|
+
> 因为 `session.event` 会把 `session/title` 推给我们,**活跃会话的标题是实时到手的**,不需要读磁盘。
|
|
120
|
+
|
|
121
|
+
### 2.3 内容模型:灰色字 vs 黑色字
|
|
122
|
+
|
|
123
|
+
DSH 在 LLM 层就区分两种增量:`text-delta`(黑)与 `reasoning-delta`(灰),并有 `joinAssistantStreamText()` 这类"只拼 text-delta、排除 reasoning"的读取器。
|
|
124
|
+
|
|
125
|
+
落到会话事件上,**已完成的消息已经把两者分好了字段**:
|
|
126
|
+
|
|
127
|
+
```jsonc
|
|
128
|
+
{"type":"assistant/message","data":{"turn":5,"step":11,"message":{
|
|
129
|
+
"role":"assistant",
|
|
130
|
+
"content":[
|
|
131
|
+
{"type":"reasoning","text":"…灰色思考…"},
|
|
132
|
+
{"type":"text","text":"…黑色正文…"},
|
|
133
|
+
{"type":"tool-call","id":"call_…","name":"pwsh","arguments":"{…}"}
|
|
134
|
+
]}}}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
实测某真实会话:43 条 `assistant/message` 中,`reasoning` 部件 43 个、`text` 部件 14 个、`tool-call` 部件 61 个。
|
|
138
|
+
|
|
139
|
+
**重要否定结论**:持久化日志里 `assistant/attempt.stream` 只存了 `usage` 和 `finish` 块;token 级增量被**打包压缩**进 `{type:'text-chunks'|'reasoning-chunks'|'tool-call-chunks', time0, index, dt[], texts[]}` 记录(`AssistantStreamRecord`),不是逐条实时事件。SDK 只转发持久化事件(`ctx.on("session/event") → notify("session.event")`),所以:
|
|
140
|
+
|
|
141
|
+
> **通过 SDK 协议拿不到真正的逐字流式增量。** 能拿到的是"每个 step 一条完整 assistant/message"。想在 MCP 侧做"逐字流式"必须换集成点(web 通道或 in-process 插件),代价很大——不建议 MCP 首选方案走这条路。见 §6 给出的替代设计。
|
|
142
|
+
|
|
143
|
+
### 2.4 DSH 自身能力面与 profile
|
|
144
|
+
|
|
145
|
+
Profile = `~/.dsh/profiles/<name>/`,由 `package.json` 的 `dsh.profile.bundles`(bundle 层)+ `cordis.patch.yml`(用户覆盖层,**`{id, name, config}` 数组,支持 `!!js` 表达式**)+ `--patch` 叠加组成。
|
|
146
|
+
|
|
147
|
+
实测 `--dump-config` 得知:
|
|
148
|
+
|
|
149
|
+
- **`sdk` profile 并不裸**:`@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-sdk-app`,已含全套编码能力——fs 读写、pwsh/bash、文件搜索、技能、agent 指令(AGENTS.md)、todo、目标、计划模式、子代理(spawn/fork)、workflow、压缩与工具结果剪枝、token 计量、沙箱与审批、会话持久化与投影缓存、会话标题。
|
|
150
|
+
- **它缺的是部署方的私有配置**:`llm-pi-ai` 没有 config(所以 `initialize` 里传一个未注册的 provider 名,会直接得到 `no adapter registered for provider "..."`),`agent-default-model` 仍是出厂默认(`deepseek-official/deepseek-flash`)。
|
|
151
|
+
私有 provider 的完整定义(baseURL、apiKeyEnv、模型与 reasoningEfforts、contextWindow/maxTokens)只存在于你自己的 profile 补丁层里 —— 所以**本服务不自带任何 provider 配置**,请在你自己的 profile 中声明。
|
|
152
|
+
- 可直接用环境变量控制权限:`DSH_PERMISSION_MODE`(默认 `workspace-write`;设为 `danger-full-access` 时审批策略自动变成 `never`)。
|
|
153
|
+
|
|
154
|
+
**结论:我们必须自带一个专用 profile(例如 `mcp-bridge`),把用户的 provider/model 与所需能力 bundle 显式列进去**,不能直接复用 `sdk`。
|
|
155
|
+
|
|
156
|
+
### 2.5 让 DSH 用"自己的 MCP"
|
|
157
|
+
|
|
158
|
+
实测:`dsh-base` 和 web profile **都只挂了 `@deepseek-ai/dsh-mcp-resources`,没有挂 `@deepseek-ai/dsh-mcp-client`**;`.dsh` 下也没有任何现存 MCP 配置。所以"让 DSH 用 MCP"是纯新增能力,机制是往 profile 补丁层加条目:
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
- id: mcp-github
|
|
162
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
163
|
+
config:
|
|
164
|
+
serverName: github # [A-Za-z0-9_-]{1,32},同作用域内唯一
|
|
165
|
+
transport: stdio # 或 streamable-http
|
|
166
|
+
command: npx
|
|
167
|
+
args: ['-y', '@modelcontextprotocol/server-github']
|
|
168
|
+
env: { GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN }
|
|
169
|
+
toolCallTimeoutMs: 60000
|
|
170
|
+
failOnStartupError: false
|
|
171
|
+
reconnect: { enabled: true, initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: 10 }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
要点:
|
|
175
|
+
- 工具以 `mcp__<serverName>__<rawName>` 注册——**和 Claude Code / Codex 的命名形状一致**。
|
|
176
|
+
- 默认**不启用任何服务器**;stdio 连接前会先起一个临时探针进程。
|
|
177
|
+
- `env` 是在**擦洗过的环境**上合并:环境里匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 的变量和所有 `DSH_*` 会被丢弃,必须显式传。
|
|
178
|
+
- 不支持 MCP prompt 模板;资源按需读取。
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 3. 五个硬约束(设计必须先认下来)
|
|
183
|
+
|
|
184
|
+
1. **一个会话同一时刻只能有一个写者。** 写锁活到进程退出且不过期。⇒ "保持对话"= 长驻 SDK 进程;同时**用户在自己的 GUI/TUI 里打开同一个会话会失败**。因此必须有空闲回收 + 显式"交还会话"。
|
|
185
|
+
2. **`initialize` 的参数是进程级全局的。** 换 `cwd`/`model`/`reasoningEffort` 必须换进程 ⇒ 进程池按 `(cwd, provider, model, reasoningEffort)` 分桶。
|
|
186
|
+
3. **SDK 不提供会话列表/标题/重命名/中断。** ⇒ 列表读磁盘(投影缓存 + 日志 + `workspace.json`)、标题从 `session/title` 事件拿、重命名只能做我们自己的别名层、中断需要用别的办法(见开放问题 Q3)。
|
|
187
|
+
4. **MCP 拿不到逐字流式。** 且 MCP 工具结果最终是"文本进模型上下文",宿主 UI 才会渲染 ⇒ 见 §6。
|
|
188
|
+
5. **递归风险。** DSH 能挂 MCP,我们能挂 DSH。若把 dsh-any-agent 自己注册进 DSH 的 MCP 列表 → 无限递归。必须显式拒绝/检测。
|
|
189
|
+
|
|
190
|
+
6. ~~**会话不可恢复、回合不可中断、审批不可应答**~~ → ✅ **已在 v0.2 由改用 `acp` profile 解决**(见 §0)。以下仅作为"若坚持用 SDK 协议"的约束记录:
|
|
191
|
+
- SDK 下复用已存在的会话 id → 报错;进程死 = 该会话永久只能只读;
|
|
192
|
+
- SDK 下没有任何 cancel/interrupt 方法,**唯一停止回合的办法是杀进程**(代价同上);
|
|
193
|
+
- SDK 下没有审批通道,服务端从不发 server→client 请求,默认 `workspace-write` + `ask` 时所有需审批操作都以 `unavailable` **静默失败**(fail-closed)。
|
|
194
|
+
- **ACP 下三条全部可用**:`session/resume` / `session/cancel` / `session/request_permission`。因此权限档也不必再被迫只留"完全权限"——中间档配审批往返是可行的。
|
|
195
|
+
- 仍建议默认 `DSH_PERMISSION_MODE=danger-full-access`(少往返、少摩擦),但现在是**选择**而非**无奈**。注意 `approval.policy: never` 的语义是"每次询问都确定性拒绝",不是自动放行。
|
|
196
|
+
7. **新增 profile 行必须用 `insert:`。** 裸写 `- id: xxx` + `name:` 是"覆盖已存在的行"语义,匹配不到 id 时只打印 `patch: entry not found` 然后**静默跳过**(官方 README 的示例恰好漏了 `insert:`,是个坑)。
|
|
197
|
+
|
|
198
|
+
补充:`workspace.json` 是单文件共享存储(原子写、后写覆盖),我们的进程和用户 GUI 同时改它可能互相覆盖 ⇒ 固定/归档这类写入要克制。
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 4. 功能清单
|
|
203
|
+
|
|
204
|
+
### A. 会话生命周期(用户点名要的"对话名字和 id"+"保持对话")
|
|
205
|
+
|
|
206
|
+
| # | 功能 | 说明 |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| A1 | 新建会话 | 绑定 `cwd`(工程目录)、`model/provider`、`reasoningEffort`、权限档,生成 `session-<uuid>` 作为 id,返回 id + 标题 |
|
|
209
|
+
| A2 | 列出会话 | 合并三源:我们自己的注册表(活跃进程)→ 投影缓存(标题/token/上下文压力)→ `workspace.json`(按工程分组、置顶/归档)。支持按 `cwd`、时间、状态过滤 |
|
|
210
|
+
| A3 | 会话详情 | 标题、id、cwd、模型、创建/更新时间、回合数、token 用量、上下文压力、最后一条消息摘要、是否"活着" |
|
|
211
|
+
| A4 | 改标题 | DSH 自动标题只读;我们维护别名层(可写进我们自己的注册表),返回"原生标题 vs 别名"两个字段 |
|
|
212
|
+
| A5 | 恢复会话 | **SDK 协议不支持**(既有 id 会报 `already exists`)。可行路径只有:① 让 SDK 常驻进程永不死(配合 I3/I4);② 新建会话并把旧 transcript 作为种子注入(伪造历史,注意 `isSeeded`/`session/end-seed`);③ 自己写一个 DSH 插件补 `session/resume` 方法。headless 的 `--session-id` 也不能用作替代——它受 `assertAdoptable` 限制,**拒绝任何带 `agentPreset` 的会话**,而 web profile 创建的会话全部带 `agentPreset: "standard"`(实测 5/5 全被拒) |
|
|
213
|
+
| A6 | 交还会话 | 关闭进程、释放写锁,把会话还给用户 GUI;`keep`(保留历史)vs `delete` |
|
|
214
|
+
| A7 | 删除会话 | 删会话目录 + 投影缓存条目 + 注册表,需二次确认语义 |
|
|
215
|
+
| A8 | 置顶/归档 | 写 `workspace.json` 的 `pinnedSessionIds` / `archivedSessionIds`(注意约束 5 的写冲突) |
|
|
216
|
+
| A9 | 跨会话搜索 | 可选启用 `dsh-session-query-sqlite`(默认 `path: ':memory:', openAt: never`)建索引后检索历史 |
|
|
217
|
+
| A10 | 分叉会话 | 从某轮切出新会话(拷贝前缀事件 + 新 id) |
|
|
218
|
+
|
|
219
|
+
### B. 任务执行与运行控制
|
|
220
|
+
|
|
221
|
+
| # | 功能 | 说明 |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| B1 | 发任务(阻塞) | 发 prompt,等到 `session.status = idle`(或 `turn/end`)再返回结果 |
|
|
224
|
+
| B2 | 发任务(异步) | 立即返回 `run_id` + `sentinel_file`;回合终止时原子写出含最终结果的哨兵文件(见 B9),调用方用文件系统等待"跑完自动通知",无需轮询 |
|
|
225
|
+
| B3 | 游标增量读取 | `read(run_id, cursor)` → 自上次以来的黑色正文 + 工具事件 + 新 cursor("流式"的落地形态) |
|
|
226
|
+
| B4 | 打断 | 停止当前回合(协议无中断方法,实现路径见 Q3) |
|
|
227
|
+
| B5 | 运行状态 | `idle/running`、当前 turn/step、正在跑哪个工具 |
|
|
228
|
+
| B6 | 用量计量 | `tokenUsage.totals` / `contextPressure` / `contextWindow`,让调用方能管预算 |
|
|
229
|
+
| B7 | 回合排队 | 同会话内串行排队(DSH 单会话单回合),避免并发踩踏 |
|
|
230
|
+
| B8 | 上下文注入 | `contentBlocks` 支持文本与图片;支持 DSH 的 `@路径` 引用语法把文件带进会话 |
|
|
231
|
+
| B9 | 异步完成哨兵 | `wait=false` 的 run 终止(done/error/cancelled)时,往 `<RUNS_DIR>/<conversation_id>/<run_id>.json` 原子写出含 `status`+`result` 的文件;调用方(Claude/Codex)用后台任务 `until [ -f ]` 等它出现即被唤醒,无需轮询、也不占轮次 |
|
|
232
|
+
| B10 | 写锁抢占 | 写锁是跨进程内核信号量(持有者活着不过期、**无夺锁 API**),所以"抢"= 杀掉持有者。本服务登记自己拉起的子进程(`<状态目录>/locks/<会话id>.json`:pid + token + 心跳),据此把持有者分成四类,只对**判定为本服务残留的孤儿**自动动手;无法识别的一律不杀(见下) |
|
|
233
|
+
|
|
234
|
+
**B9 设计要点(并发正确性)**:本服务在 MCP 协议上只应答请求、**不主动推通知**,"跑完通知"由哨兵文件 + 调用方后台等待任务共同实现。
|
|
235
|
+
- **按会话分目录**:`run_id` 序号是每会话独立计数的,不同会话可能同毫秒生成同名 `run_id`;`<conversation_id>/` 前缀使其各写各的、不串台。会话内 `dsh_send` 忙时直接拒绝、序号单调,故会话内唯一。
|
|
236
|
+
- **闩锁语义**:文件写一次并保留 —— 即使 run 在等待任务启动前就完成,`[ -f ]` 也立刻为真,不漏。
|
|
237
|
+
- **原子落地**:`tmp`(带 pid+随机后缀) + `rename`,存在即完整;每个 run 独立 tmp,并发写互不覆盖(**刻意不复用** `save()` 那个共享 `${STATE_FILE}.tmp`)。
|
|
238
|
+
- **自带结果**:内存 run 记录只留最近 `MAX_RUNS` 条、并发多 run 时会被挤掉;哨兵内含 `result`,收活读文件即可,不依赖内存、且扛服务重启。
|
|
239
|
+
- **隐私(唯一的落盘例外)**:哨兵必须带上正文,调用方才能离线收活——所以它是本项目**唯一**会把对话内容写到磁盘的地方。但 `result.thinking` 默认被**剥掉**并置 `thinking_omitted: true`(`sanitizeResultForSentinel`),维持"思考不落盘"的总体保证;要连思考一起落盘必须显式设 `DSH_MCP_SENTINEL_INCLUDE_REASONING=1`。测试 `test/sentinel.mjs` 专门守这一点:请求 `reasoning=full` 时哨兵原文里不得出现思考文本,而同一 run 在内存(`dsh_get`)里仍可见。
|
|
240
|
+
- **留存**:哨兵是闩锁、不会自行消失,且内含正文,故服务**启动时**按 `DSH_MCP_SENTINEL_TTL_MS`(默认 7 天)清理过期哨兵与崩溃残留的 `.tmp`(`pruneSentinels`)。只在启动做一次,因此绝不会误删"当前正在等待的"哨兵。
|
|
241
|
+
- **给调用方的便利**:返回值同时给出 `sentinel_file_posix`(`D:\a\b` → `/d/a/b`)。手工转换 Windows 路径是等待任务白等到超时最常见的原因。
|
|
242
|
+
- **落点**:`tools.dsh_send` 的 `wait=false` 分支在 `.then`/`.catch` 各调一次 `hub.writeRunSentinel()`(覆盖所有终止态);`createRun` 起跑前 `clearRunSentinel()` 防御历史残留。
|
|
243
|
+
|
|
244
|
+
**B10 设计要点(写锁与抢占)**
|
|
245
|
+
- **为什么需要**:DSH 的 `SessionWriteLease` 是 Windows 命名内核信号量,**持有者活着就永不过期**,且**没有 API 能从活进程手里夺走** —— 唯一手段是杀掉持有者进程。既然要杀,就必须先判定那是谁。
|
|
246
|
+
- **登记**:一个会话一个文件 `<状态目录>/locks/<会话id>.json`,内容 `{mcp_pid, mcp_token, child_pid, cwd, spawned_at, heartbeat_at}`。`mcp_token` 防 PID 复用;`heartbeat_at` 由 reaper 周期刷新。
|
|
247
|
+
- **四类判定**(`locks.classifyHolder`):`self`(自己)/ `stale-mcp`(MCP 已死或心跳过期 → 可安全抢占的孤儿)/ `live-mcp`(另一活着的实例)/ `none`(无登记,多半是用户的 GUI)。
|
|
248
|
+
- **登记时机 = 拿到锁之后**:`writeMarker` 只在 `session/resume` **成功之后**调用,而不是 spawn 完就写。语义是"**我正持有**"而不是"我拉起了进程" —— 否则一次注定撞锁失败的 resume 会先把"谁持有"的线索覆盖掉、失败路径再删掉,抢占逻辑就只能看到 `none`(实现期真实踩到两次)。
|
|
249
|
+
- **不覆盖原则**:`writeMarker` **绝不覆盖"别人还活着的"登记**(额外一层保险);`removeMarker` 默认只删自己的(校验 token)。
|
|
250
|
+
- **★ 崩溃不会留孤儿(实测结论)**:子进程的 stdio 是连着父进程的管道,`SIGKILL` MCP → 管道关闭 → 子进程见 EOF 自杀 → **锁自动释放**。所以 `stale-mcp` 真正对应的是"**持有者活着但卡死**"(心跳过期),而不是"崩溃残留"。(代价:崩溃时进行中的回合会中断且**哨兵不会落地**,只能靠核对工作区状态。)
|
|
251
|
+
- **两条独立 spawn 路径都要登记**:`ensureAlive()` **和** `Hub.create()` —— 新建会话走后者,漏登记会让"本服务自己持有的会话"被判成 `none` 而拒绝抢占(同样真实踩到)。
|
|
252
|
+
- **★★ "在 GUI 里看进度"会抢走写锁(A/B 实测)**:用一个独立进程去 `session/resume` 探测同一会话 —— 会话只在 MCP 手里时**【空】**(连测两次);用户在 web 里**点开**它之后**【被占用】**(`already owned by an active write handle`),且当时唯一的 MCP 服务**没有任何子进程**,持有者就是 `dsh web` 进程(它为此拉起 `--profile dsh-mcp` 助手)。结论:**"读"不受影响,但"打开"这个动作会占锁**。
|
|
253
|
+
→ 这正是"任务跑完后调用方接不回去"的典型成因(旧代码还会把它包成 `Internal error`)。所以运行中的回合**不要在 GUI 里点进去看**;要看先 `dsh_release`。
|
|
254
|
+
→ **★ 而且"切走"不会还回来(同样实测)**:你切到别的会话之后再探测,锁**仍然**被 `dsh web` 持有。**点开一次 = 这个会话在整个 web 进程生命周期内归 GUI**,MCP 要等重启 `dsh web` 才能再碰它。这才是"**总是**接不回去"的原因(不是每次都被抢,而是抢一次就长期生效)。看进度应该用 `dsh_read`。
|
|
255
|
+
→ **★ 归档也不释放(实测,且已核实归档确实生效)**:文档说"归档主 Session 会释放其 reference",但写锁没跟着释放。**唯一已知的解除方式 = 重启 `dsh web`**(数据无损,重启后照常 resume)。
|
|
256
|
+
→ **★★ 因此无损解法是"别让锁空出来"**:**`DSH_MCP_IDLE_TTL_MS=0`(永不回收,现为默认)** → MCP 一直握着写锁 → GUI 抢不走。**实测**:你在 GUI 里点开它是**只读打开**(照样看得到内容 ✓),用独立实例探测确认**持有者仍是本服务的 PID、心跳新鲜** ✓ —— 即"**你看得见,我们写得动**",两边都不受影响。代价是每会话常驻一个 DSH 进程。`test/cycle.mjs` 第 5 节守着这条语义(TTL=0 时子进程不被收、`lock_holder` 仍是 `self`、心跳仍在续)。
|
|
257
|
+
- **多实例共存(实测)**:多个实例共用一份注册表时 `save()` 是"整份文档、最后写入者获胜" —— 后保存的会把**它没见过**的会话从文件里抹掉(`test/multi.mjs` 复现)。因此**注册表只当缓存**:`Hub.get(id)` 查不到时用 `findSessionHeader(id)` 从**会话存储**按 id 接回来(跨分桶扫会话头),权限档取自**会话自己的记录**(`permissions.preset`),所以"只有 id"也足以驱动任何磁盘上存在的会话,且**不会提权**。另:`save()` 的临时文件必须**唯一命名**(pid + 随机),否则多实例并发保存会互相踩踏(写死 `${STATE_FILE}.tmp` 是真实 bug)。
|
|
258
|
+
- **★★ 权限档的无声提权(最严重的一个,`test/permission.mjs` 守着)**:profile 里写 `defaultPreset` 会让 `dsh-permission-presets` 在**会话创建时**把它应用到 sandbox 模式与审批策略,**盖掉** `dsh-base` 从 `DSH_PERMISSION_MODE` 推导的模式 —— 于是 `dsh_start(permission:'read-only')` 返回 read-only 而会话实为完全权限。**正确写法:只定义预设、不写 `defaultPreset`**(插件改用"按 sandbox+approval 组合推断"的默认值,正好与三档一一对应)。**验证方式很关键**:不读工具的返回值(那正是会说谎的地方),而读**会话自己记录的事实**(投影缓存 `permissions.preset` / `sandboxMode`)。附带结论:档位在 `dsh_start` 时定下,DSH 设计上**resume 会保留会话已记录的权限**,事后改参数不生效。
|
|
259
|
+
- **错误翻译**:`SessionAlreadyOwnedError` 不是 `RequestError`,ACP 把它包成 `-32603 "Internal error"` 并把真实原因放 `error.data`;本服务取出 `data`,识别 `already owned` / `writer-held` / 中文「已被占用」,转成"谁持有 + 该怎么办"。
|
|
260
|
+
- **★ 收尾必须等进程真死**:`AcpProcess.stop()` 在超时强杀后**再等 'exit'**(`killWaitMs`),回收时也**先 `stop()` 再删登记**。旧实现 kill 完立刻 resolve,于是"子进程还活着、还握着锁"时我们就往下走:删登记 → spawn 新进程 → resume 撞上一个**无法归因的锁**(登记没了 → `none` → 连 `takeover` 都拒绝)。这正是"异步派活 → 等 task → 回头再进就报有锁"的一个真实来源(`test/cycle.mjs` 守着)。
|
|
261
|
+
- **★ `busy` 必须在 `ensureAlive()` 之前置位**:否则"把进程拉起来"(spawn + initialize + resume,可能好几秒)是一个**裸奔窗口** —— ① reaper 看到 `busy=false` 且 `lastUsedAt` 已过期,会把**刚拉起来**的进程当空闲回收掉,紧接着 resume 就报"DSH 进程未运行";② 第二个 `dsh_send` 也能通过 busy 检查,为同一会话再拉一个子进程(互相撞锁 + 泄漏)。失败路径必须放开 `busy`,否则会话被永久占住。(`test/cycle.mjs` 实测抓到。)
|
|
262
|
+
- **兜底归因 `lastChildPid`**:`Conversation` 记住本进程为该会话拉起过的最后一个子进程 PID;`classifyHolder(id, {knownChildPid})` 在"登记已被删、但那个 PID 还活着"时仍判为 `stale-mcp`(可自动抢占),而不是误判成 `none` 拒绝。
|
|
263
|
+
- **绝不杀 GUI**:GUI 会话跑在 `dsh web` 单进程内(非一会话一进程),杀它 = 整个界面 + 该进程全部 GUI 会话一起断。因此 `none` 分支只报告不动手。
|
|
264
|
+
- **多实例可见性**:`Hub.get()` 命中不到时先 `adoptNewFromDisk()`(只增补、不覆盖内存中的活会话),否则"另一个实例刚建的会话"会被误报成「未知会话」。
|
|
265
|
+
|
|
266
|
+
## C. 输出与思考控制(用户点名要的"默认隐藏思考内容")
|
|
267
|
+
|
|
268
|
+
| # | 功能 | 说明 |
|
|
269
|
+
|---|---|---|
|
|
270
|
+
| C1 | 思考档位 | `reasoning: hide`(默认)/ `marker`(只留占位与字数)/ `summary`(首 N 行或首尾)/ `full` |
|
|
271
|
+
| C2 | 输出形态 | `answer`(只要黑色正文)/ `transcript`(含工具调用与结果)/ `events`(原始事件流,调试用) |
|
|
272
|
+
| C3 | 工具调用展示 | 是否展示工具名/参数/结果;`hide_tool_args` 单独开关(参数里可能带密钥) |
|
|
273
|
+
| C4 | 密钥脱敏 | 返回值过一遍脱敏(真实会话日志里出现过明文 API key、token) |
|
|
274
|
+
| C5 | 长度与溢出 | `max_output_chars` + 超限落盘并返回文件引用(DSH 自己也有 spill:`maxInlineTokens: 12500`、工具结果剪枝 `thresholdChars: 8192`) |
|
|
275
|
+
| C6 | 思考统计 | 即使隐藏内容,也返回"思考了 N 字 / 用了 M 秒 / 几个步骤",让调用方知道发生了什么 |
|
|
276
|
+
|
|
277
|
+
### D. 流式与进度(对应"黑色字仍然流式传输")
|
|
278
|
+
|
|
279
|
+
| # | 功能 | 说明 |
|
|
280
|
+
|---|---|---|
|
|
281
|
+
| D1 | 进度通知 | MCP `notifications/progress`:阶段化推送(启动→思考→写代码→跑工具→完成) |
|
|
282
|
+
| D2 | 灰色思考走日志通道 | 用 MCP `notifications/message`(log, level=debug/info)推送思考——多数宿主渲染成**暗色/折叠旁注**,这正是"灰色字"最接近的等价物,且**不污染模型上下文** |
|
|
283
|
+
| D3 | 黑色正文增量 | 两种:游标轮询(B3)或"分块结果"(每步返回一段) |
|
|
284
|
+
| D4 | 订阅 | 会话 transcript 作为 MCP resource + `resources/subscribe`(Claude 支持;DSH 的 mcp-client 不支持,对称性要注意) |
|
|
285
|
+
| D5 | 心跳 | 长任务期间定期推进度,避免宿主判定超时 |
|
|
286
|
+
|
|
287
|
+
### E. 代码 / 脚手架专用(这是"用来做脚手架"的核心价值)
|
|
288
|
+
|
|
289
|
+
| # | 功能 | 说明 |
|
|
290
|
+
|---|---|---|
|
|
291
|
+
| E1 | **返回变更 diff** | 回合结束后返回工作区变更(`workspace/changes` 事件 + `git diff --stat`)。**让调用方 agent 能审阅 DSH 的产出再决定是否接受**——这是"外包工程师"模式的关键 |
|
|
292
|
+
| E2 | 计划模式 | 只出计划不落地(`dsh-plan-mode`),调用方批准后再执行 |
|
|
293
|
+
| E3 | 审查模式 | 对既有改动做对抗式审查 |
|
|
294
|
+
| E4 | 接受/回滚 | 会话级 checkpoint(DSH 有 `dsh-session-checkpoint-policy`)配合 git 做回滚 |
|
|
295
|
+
| E5 | 工程模板 | 把常见脚手架套路做成 MCP prompt 模板(如"初始化一个 X 项目") |
|
|
296
|
+
| E6 | 测试/构建 | 不必单独做工具——DSH 自己有 pwsh/bash,发任务即可 |
|
|
297
|
+
|
|
298
|
+
### F. DSH 能力面("允许 DSH 使用自己的 MCP")
|
|
299
|
+
|
|
300
|
+
| # | 功能 | 说明 |
|
|
301
|
+
|---|---|---|
|
|
302
|
+
| F1 | **生成专用 profile** | 首次运行脚手架出 `~/.dsh/profiles/mcp-bridge/`:bundles + provider/model + 权限档 + 标题 LLM 打开 |
|
|
303
|
+
| F2 | **MCP 服务器增删查** | 往 profile 补丁层写 `dsh-mcp-client` 条目:`mcp_list` / `mcp_add` / `mcp_remove` / `mcp_test`(连通性探针) |
|
|
304
|
+
| F3 | 插件管理 | `plugins_list` / `plugin_add` / `plugin_remove`(走 `dsh plugin --profile …`) |
|
|
305
|
+
| F4 | 技能清单 | 列出 filesystem 技能与 office 技能 |
|
|
306
|
+
| F5 | 配置查看 | `--dump-config` 的等价能力,返回合成后的插件树 |
|
|
307
|
+
| F6 | 模型清单 | 可用 provider/model/`reasoningEffort` 档位(从 profile 配置解析) |
|
|
308
|
+
| F7 | 指令文件 | 报告 AGENTS.md / agent-instructions 的发现结果(`maxBytes: 65536`) |
|
|
309
|
+
| F8 | 自检 doctor | 凭据是否可用、profile 是否就绪、node 版本、Windows 执行策略、zstd 可用性 |
|
|
310
|
+
|
|
311
|
+
### G. 权限与审批(MCP 场景下最容易出事的地方)
|
|
312
|
+
|
|
313
|
+
| # | 功能 | 说明 |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| G1 | 权限档 | **默认 `danger-full-access`,其余档位可选**(用户已决策)。ACP 有审批往返后 `workspace-write` 不再是废档——它的越界操作会以 `session/request_permission` 冒泡给调用方裁决。`read-only` 适合分析不信任样本(恶意软件/来路不明 dump)的场景。⚠️ 任何档位都**不放开** `fs-observation-policy`(强制先读后写,要关需单独 patch 该行 `disabled: true`) |
|
|
316
|
+
| G2 | 环境变量通道 | 用 `DSH_PERMISSION_MODE` 控制沙箱与审批(`danger-full-access` ⇒ 审批 `never`) |
|
|
317
|
+
| G3 | **审批转交调用方裁决** | ✅ **v0.2 恢复**:ACP 有 `session/request_permission` 客户端往返(`dsh-acp/lib/index.js:1134`),所以 `dsh_approval_decide(conversation_id, approval_id, outcome)` 这类工具**做得出来**——把 DSH 的越权/提权请求冒泡给 Codex/Claude 决定,"一律放行"和"一律拒绝"之外的第三条路。裁定结果回落为 ACP 的 permission outcome(`allowed-once` / `rejected` / `cancelled`) |
|
|
318
|
+
| G4 | 工具白/黑名单 | 按会话限制可用工具 |
|
|
319
|
+
| G5 | 失败关闭 | 无人裁决时按 fail-closed 处理(DSH 原生行为就是如此) |
|
|
320
|
+
|
|
321
|
+
### H. MCP 协议形态
|
|
322
|
+
|
|
323
|
+
| # | 功能 | 说明 |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| H1 | Tools | 主接口,见 §5 |
|
|
326
|
+
| H2 | Resources | `dsh://conversation/<id>`、`/transcript`、`/diff`、`/profiles`、`/mcp-servers` |
|
|
327
|
+
| H3 | Prompts | 脚手架/审查模板 |
|
|
328
|
+
| H4 | 递归防护 | 拒绝把 dsh-any-agent 自身注册进 DSH 的 MCP 列表;检测调用链深度 |
|
|
329
|
+
| H5 | 能力协商 | 按客户端能力决定是否发 progress/log 通知(Codex 与 Claude 支持度不同) |
|
|
330
|
+
|
|
331
|
+
### I. 运维
|
|
332
|
+
|
|
333
|
+
| # | 功能 | 说明 |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| I1 | 进程池 | 按 `(cwd, provider, model, reasoningEffort)` 复用长驻 SDK 进程 |
|
|
336
|
+
| I2 | 并发控制 | 全局上限 + 每会话互斥 |
|
|
337
|
+
| I3 | 空闲回收 | TTL 到期 shut down,**释放写锁**,把会话还给用户 |
|
|
338
|
+
| I4 | 崩溃恢复 | 注册表持久化到磁盘;启动时对账(哪些会话还活着、哪些锁被占) |
|
|
339
|
+
| I5 | 可观测 | 保留 DSH 的 stderr 诊断 + 自己的 NDJSON 事件日志,便于排障 |
|
|
340
|
+
| I6 | Windows 适配 | **本机 `dsh.ps1` 被执行策略拦住**(`running scripts is disabled`)⇒ 必须用 `node <bin.js>` 或 `dsh.cmd` 拉起,不能依赖 `.ps1` shim |
|
|
341
|
+
| I7 | 多 `DSH_HOME` | 支持隔离环境(测试/多账号),默认共用 `~/.dsh` 以共享凭据与历史 |
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## 5. 工具接口草案
|
|
346
|
+
|
|
347
|
+
工具名统一 `dsh_` 前缀,避免与调用方自身工具撞名。
|
|
348
|
+
|
|
349
|
+
**会话**
|
|
350
|
+
|
|
351
|
+
| 工具 | 主要入参 | 返回 |
|
|
352
|
+
|---|---|---|
|
|
353
|
+
| `dsh_start` | `cwd, model?, provider?, reasoning_effort?, permission?, reasoning_mode?, title?` | `{conversation_id, title, cwd, model, created_at}` |
|
|
354
|
+
| `dsh_list` | `cwd?, limit?, include_archived?, only_alive?` | `[{id, title, cwd, updated_at, alive, turns, tokens}]` |
|
|
355
|
+
| `dsh_get` | `conversation_id` | `{id, title, alias?, cwd, model, turns, usage, context_pressure, last_message, alive}` |
|
|
356
|
+
| `dsh_rename` | `conversation_id, alias` | `{id, native_title, alias}` |
|
|
357
|
+
| `dsh_resume` | `conversation_id, permission?` | ⚠️ 见 A5,SDK 下不可行;退化为"新建 + 注入旧 transcript" |
|
|
358
|
+
| `dsh_release` | `conversation_id, delete?` | `{released, deleted}` |
|
|
359
|
+
| `dsh_takeover` | `conversation_id, force?` | `{ok, holder_kind, killed_pid, message}` —— 四类持有者分别处置(见 B10) |
|
|
360
|
+
| `dsh_delete` | `conversation_id, confirm` | `{deleted}` |
|
|
361
|
+
| `dsh_search` | `query, cwd?, limit?` | `[{id, title, seq, snippet}]` |
|
|
362
|
+
|
|
363
|
+
**执行**
|
|
364
|
+
|
|
365
|
+
| 工具 | 主要入参 | 返回 |
|
|
366
|
+
|---|---|---|
|
|
367
|
+
| `dsh_send` | `conversation_id, prompt, wait?, timeout_ms?, reasoning?, output?, max_chars?` | **默认 `wait=false`**:`{run_id, accepted, background, sentinel_file}`(完成哨兵见 B9);`wait=true`:`{run_id, status, answer, thinking_stats, tools_used, diff, usage, cursor}`;`wait=true` 等超时 → 收据(`still_running`),**回合不取消、结果不丢** |
|
|
368
|
+
| `dsh_run` | 同上但 `wait=false` 语义 | `{run_id, sentinel_file}` |
|
|
369
|
+
| `dsh_read` | `run_id, cursor?, reasoning?` | `{status, text_delta, events, cursor, done}` |
|
|
370
|
+
| `dsh_status` | `conversation_id` | `{status, turn, step, current_tool}` |
|
|
371
|
+
| `dsh_interrupt` | `conversation_id` | `{interrupted}` |
|
|
372
|
+
| `dsh_transcript` | `conversation_id, from_seq?, to_seq?, reasoning?, include_tools?` | 结构化 transcript |
|
|
373
|
+
| `dsh_diff` | `conversation_id, run_id?` | `{files, patch, stat}` |
|
|
374
|
+
| `dsh_decide_approval` | `conversation_id, approval_id, outcome` | `{decided}` |
|
|
375
|
+
|
|
376
|
+
**DSH 能力面**
|
|
377
|
+
|
|
378
|
+
| 工具 | 主要入参 | 返回 |
|
|
379
|
+
|---|---|---|
|
|
380
|
+
| `dsh_mcp_list` | `profile?` | `[{id, serverName, transport, command/url}]` |
|
|
381
|
+
| `dsh_mcp_add` | `profile?, serverName, transport, command?/args?/env?/cwd?/url?/headers?` | `{added, probed, tools[]}` |
|
|
382
|
+
| `dsh_mcp_remove` | `profile?, id` | `{removed}` |
|
|
383
|
+
| `dsh_profiles` | — | `[{name, bundles, path}]` |
|
|
384
|
+
| `dsh_profile_create` | `name, from?, providers?, model?` | `{created, path}` |
|
|
385
|
+
| `dsh_plugins` | `profile?` | `[{id, name, disabled}]` |
|
|
386
|
+
| `dsh_skills` | `cwd?` | `[skill]` |
|
|
387
|
+
| `dsh_models` | `profile?` | `[{provider, models:[{id, reasoningEfforts, contextWindow}]}]` |
|
|
388
|
+
| `dsh_doctor` | — | `{credentials, profile, node, execPolicy, zstd, ok}` |
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## 6. "灰色字"在 MCP 里到底长什么样(重要澄清)
|
|
393
|
+
|
|
394
|
+
你的直觉来自 DSH 的终端 UI:思考是灰的、正文是黑的,都逐字滚出来。**MCP 没有颜色通道,也没有"把工具执行中的字符实时塞进模型上下文"的标准机制**——工具结果最终是一段文本,由**宿主的 UI** 决定怎么渲染。所以要拆成两件事来满足:
|
|
395
|
+
|
|
396
|
+
**① 默认隐藏思考 → 直接做到位(我们没有这个问题)**
|
|
397
|
+
`assistant/message.content[]` 已经把 `reasoning` 和 `text` 分成不同部件,默认丢弃 `reasoning` 即可,零成本、无歧义。同时返回"思考统计"(C6),让调用方知道 DSH 想过但看不到内容。
|
|
398
|
+
|
|
399
|
+
**② 想看思考时,用三条通道模拟"灰色"**
|
|
400
|
+
|
|
401
|
+
| 通道 | 效果 | 代价 |
|
|
402
|
+
|---|---|---|
|
|
403
|
+
| 塞进工具结果,用引用块/`<thinking>` 包裹 | 多数终端会以缩进/暗色渲染引用块,最接近"灰色" | 占用模型上下文(可能很贵) |
|
|
404
|
+
| 走 `notifications/message`(MCP log) | 宿主通常渲染为**暗色或折叠的旁注**,视觉上就是"灰色",且**不占模型上下文** | 需要宿主支持 log 通知;不进模型视野 |
|
|
405
|
+
| 走 `notifications/progress` | 只显示"正在思考(已隐藏 1.2k 字)" | 看不到内容,但能感知节奏 |
|
|
406
|
+
|
|
407
|
+
**推荐默认组合**:结果里 `reasoning: hide` + 思考走 log 通知(灰色、可折叠、不烧 token)+ 正文走进度与分块结果(黑色)。
|
|
408
|
+
|
|
409
|
+
**③ "黑色字流式"的现实边界**
|
|
410
|
+
逐字流式是**宿主 UI 的能力**,MCP Server 只能提供"可增量拉取"和"进度推进"。所以落地为:
|
|
411
|
+
- `dsh_send(wait=false)` 拿 `run_id` → `dsh_read(run_id, cursor)` 反复拉增量(调用方 agent 每步都能看到新内容);
|
|
412
|
+
- 同时用 progress/log 通知让**人**在 Codex/Claude 的终端里看到滚动的进度。
|
|
413
|
+
|
|
414
|
+
如果确实想要"人对着终端看灰黑逐字滚动",正确做法是**另配一个瘦 CLI/TUI 伴生进程**(直连同一个会话),而不是硬塞进 MCP。这条可以在 M3 再说。
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## 7. 非目标(明确不做,避免走偏)
|
|
419
|
+
|
|
420
|
+
1. **不把 DSH 的工具 1:1 代理出去**(不要暴露 `dsh_read_file`/`dsh_run_pwsh` 之类)。调用方 agent 已有自己的文件与命令工具;那样做只会变成又一层无意义的 shell。
|
|
421
|
+
2. **不做命令执行的通用包装**——我们的抽象单位是"对话 + 任务",不是"命令"。
|
|
422
|
+
3. **不重实现会话存储**——直接复用 DSH 的目录与投影缓存,保证用户在 DSH GUI 里能看到同一批对话。
|
|
423
|
+
4. **不让 DSH 与调用方互相递归调用**(H4)。
|
|
424
|
+
5. **不做逐字流式的内核改造**(除非 M3 明确要求另配 TUI)。
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## 8. 分期实施建议
|
|
429
|
+
|
|
430
|
+
**M1(打通闭环,最有价值)**
|
|
431
|
+
`dsh_start` / `dsh_send` / `dsh_list` / `dsh_get` / `dsh_release` + 专用 profile 脚手架(provider/model/权限/标题 LLM)+ 思考默认隐藏 + Windows 拉起方式 + 进程池与空闲回收。
|
|
432
|
+
|
|
433
|
+
**M2(把它变成好用的"外包工程师")**
|
|
434
|
+
`dsh_diff`、计划模式、`dsh_read` 游标增量、progress/log 通知、审批**报告**(G3,注意不可应答)、脱敏、`dsh_search`(需先开 `session-query-sqlite` 的 `openAt`,默认 `never` 时全文搜索直接报 `SESSION_QUERY_SEARCH_DISABLED`)、resources/prompts。
|
|
435
|
+
|
|
436
|
+
**M3(能力面与体验)**
|
|
437
|
+
MCP 服务器增删查(F2)、插件与技能管理、`dsh_doctor`、分叉/回滚、可选 TUI 伴生进程。
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## 9. 需要你决策的开放问题
|
|
442
|
+
|
|
443
|
+
| # | 问题 | 我的建议 |
|
|
444
|
+
|---|---|---|
|
|
445
|
+
| Q1 | 会话空间:与用户 GUI 共用 `~/.dsh`(历史统一、但会抢写锁)还是独立 `DSH_HOME`(隔离、但要另配凭据)? | 默认共用,提供 `--dsh-home` 覆盖;MCP 起的会话打标记便于过滤 |
|
|
446
|
+
| Q2 | 空闲回收 TTL 默认值?(太长会占锁让用户打不开,太短会反复重启丢上下文) | 5 分钟,且 `dsh_release` 可显式交还 |
|
|
447
|
+
| Q3 | **中断怎么做?** SDK 协议没有 cancel,且杀进程 = 永久失去该会话(约束 6) | 强烈倾向 ②:写个极小 DSH 插件补 `session/interrupt`。① 已排除——headless 无法接管 web 建的会话 |
|
|
448
|
+
| Q4 | 默认权限档给哪一档? | ✅ **已定:默认 `danger-full-access`,其余可选。** 成因几经变化:SDK 协议没有审批通道时中间档等于废档(越界操作静默失败);**改用 ACP 后 `session/request_permission` 让中间档复活**,所以现在是"默认最省事 + 需要时可收窄",而不是被迫只留一档。`read-only` 留给分析不信任样本的场景。注意 `approval.policy: never` 的语义是"每次询问都确定性拒绝"(不是自动放行),它只在 `danger-full-access` 下无副作用——因为沙箱根本不产生询问 |
|
|
449
|
+
| Q5 | 会话 id 是否直接采用 DSH 原生 `session-<uuid>`? | ✅ 已定:采用。实测语义 = 全新 id 落成磁盘会话 id(磁盘/标题/GUI 全对齐);已存在的 id 直接报错。故**每个会话只在首次 prompt 生成一次 id,之后仅在本进程内复用** |
|
|
450
|
+
| Q6 | 首版面向 Codex 还是 Claude Code?(两者 MCP 能力不同:progress/log 通知、resources、prompts 支持度有差异) | 先保 Codex 的保守子集(tools 即可),Claude 的增强能力渐进加 |
|
|
451
|
+
| Q7 | 要不要把 `session-title-llm` 打开(每个会话多一次小 LLM 调用换更好的标题)? | 打开,标题质量对"列表里找对话"影响很大 |
|
|
452
|
+
| Q8 | 是否需要"多 DSH 会话协同"(一个 MCP 调用里让 DSH 用 subagent/workflow 并行)? | DSH 原生已经支持,M1 不用额外做,但返回值要能体现子代理树 |
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## 附:实现与验证
|
|
457
|
+
|
|
458
|
+
本文是**设计文档**,记录的是"为什么要这么设计"以及支撑它的实测证据。落地实现与可复跑的验证在别处:
|
|
459
|
+
|
|
460
|
+
| 位置 | 内容 |
|
|
461
|
+
|---|---|
|
|
462
|
+
| [`src/`](src) | 实现:ACP 客户端、会话池与注册表、工具层、MCP 服务端、工作区登记 |
|
|
463
|
+
| [`test/`](test) | 可复跑的测试套件(冒烟 / 边界 / 集成 / 异步 / 并发 / 能力 / 工作区有效性 / 验收),合计 229 项检查 |
|
|
464
|
+
| [`README.md`](README.md) / [`README.zh.md`](README.zh.md) | 使用文档(英文 / 中文) |
|
|
465
|
+
| [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml) | DSH profile 补丁层模板(含两个必踩的坑的说明) |
|
|
466
|
+
|
|
467
|
+
设计期间用于取证的一次性探针(解码多帧 zstd 会话日志、对 `--profile sdk` 做零成本握手、对 IDA broker 做完整 MCP 握手、A/B 对照验证连接失败会报错等)在实现定型后已删除;其结论已固化进本文与测试断言。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xmtlz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|