@whaletalk/dsh-codex-collab 0.1.5 → 0.1.7
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/README.md +161 -31
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +9 -2
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +41 -4
- package/cordis.patch.yml +39 -14
- package/harness/dsh-bridge.mjs +151 -11
- package/harness/session-target.mjs +53 -0
- package/mcp/dsh-mcp.mjs +14 -4
- package/package.json +2 -1
- package/templates/codex-mcp/README.md +84 -0
- package/templates/codex-mcp/cordis.patch.yml +46 -0
- package/templates/codex-mcp/package.json +12 -0
package/README.md
CHANGED
|
@@ -6,38 +6,51 @@
|
|
|
6
6
|
|
|
7
7
|
**Codex 主导,DeepSeek 执行,Codex 验收。** 一个插件体系,让 ChatGPT(Codex)像项目经理一样把编码任务派给本机 DeepSeek Harness 的编码子代理,实时回传结果,读文件验收,并由独立评审员实际跑通构建/测试后出具评审报告。
|
|
8
8
|
|
|
9
|
+
派活有**两种目标**,必须显式选一种——"接进用户正在用的那条对话"和"新建一个干活的子代理"是两件事:
|
|
10
|
+
|
|
11
|
+
| 目标 | 什么时候用 | 效果 |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `worker`(默认) | 全新任务、独立项目目录 | 在 `--cwd` 里新建持久编码子代理(多工作区 · 多 lane · fast/pro 模型) |
|
|
14
|
+
| `session` | 用户已经在某条对话里推进这件事,你只是替他继续 | 指令**投递进那条会话**,沿用它的上下文,**不新建会话/工作区** |
|
|
15
|
+
|
|
9
16
|
```
|
|
10
17
|
Codex 工作区 (ChatGPT)
|
|
11
|
-
│
|
|
18
|
+
│ dsh_task / dsh_sessions / dsh_review / dsh_read_file
|
|
12
19
|
▼
|
|
13
|
-
DeepSeek Harness 网关
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
DeepSeek Harness 网关 /api/dsh-bridge
|
|
21
|
+
├─ worker 目标 ──→ 新建编码子代理(多工作区 · 多 lane · fast/pro)─┐
|
|
22
|
+
├─ session 目标 ─→ 已有那条会话(prompt 投递,queue / steer) ──┤
|
|
23
|
+
└─ 独立评审子代理(必须跑通构建/测试)───────────────────────────┴→ 共享目录 / 原会话
|
|
17
24
|
```
|
|
18
25
|
|
|
19
26
|
```mermaid
|
|
20
27
|
flowchart LR
|
|
21
28
|
subgraph Codex["Codex (ChatGPT) — 项目经理 + 验收员"]
|
|
22
|
-
A1[
|
|
23
|
-
A2[
|
|
24
|
-
A3[
|
|
29
|
+
A1[dsh_sessions 找会话]
|
|
30
|
+
A2[dsh_task 派活]
|
|
31
|
+
A3[dsh_review 评审]
|
|
32
|
+
A4[dsh_read_file 验收]
|
|
25
33
|
end
|
|
26
34
|
subgraph DSH["DeepSeek Harness"]
|
|
27
35
|
B1[HTTP 网关 /api/dsh-bridge]
|
|
28
|
-
B2[
|
|
29
|
-
B3[
|
|
36
|
+
B2[worker 目标<br/>新建编码子代理]
|
|
37
|
+
B3[session 目标<br/>投递进已有会话]
|
|
38
|
+
B4[独立评审子代理<br/>必须跑通构建/测试]
|
|
30
39
|
end
|
|
31
|
-
subgraph FS["
|
|
32
|
-
C1[
|
|
40
|
+
subgraph FS["工作现场"]
|
|
41
|
+
C1[共享工作目录]
|
|
42
|
+
C2[已有会话与其上下文]
|
|
33
43
|
end
|
|
34
|
-
A1
|
|
44
|
+
A1 -->|只读查找| B1
|
|
35
45
|
A2 --> B1
|
|
36
|
-
A3 -->
|
|
46
|
+
A3 --> B1
|
|
47
|
+
A4 --> C1
|
|
37
48
|
B1 --> B2 --> C1
|
|
38
|
-
B1 --> B3 -->
|
|
49
|
+
B1 --> B3 --> C2
|
|
50
|
+
B1 --> B4 --> C1
|
|
39
51
|
B2 -.实时回传汇报.-> B1
|
|
40
|
-
|
|
52
|
+
B3 -.实时回传汇报.-> B1
|
|
53
|
+
B1 -.任务结果.-> A2
|
|
41
54
|
```
|
|
42
55
|
|
|
43
56
|
---
|
|
@@ -48,7 +61,10 @@ flowchart LR
|
|
|
48
61
|
|---|---|---|
|
|
49
62
|
| `codex-plugin/` | Codex 本地市场插件包 | 标准 marketplace 结构(`.agents/plugins/marketplace.json` + 插件目录),含协作技能 `dsh-collab` |
|
|
50
63
|
| `mcp/dsh-mcp.mjs` | MCP 服务器(零依赖 Node) | stdio 传输(Codex `[mcp_servers]` 直连)+ Streamable HTTP 传输(`--http`,供 Secure MCP Tunnel / cloudflared 桥接 ChatGPT);6 个工具:`dsh_task` / `dsh_sessions` / `dsh_task_status` / `dsh_task_cancel` / `dsh_review` / `dsh_read_file` |
|
|
51
|
-
| `harness/dsh-bridge.mjs` | DeepSeek Harness 宿主组合网关插件 | 提供 HTTP 网关(`/api/dsh-bridge/*`)、WS
|
|
64
|
+
| `harness/dsh-bridge.mjs` | DeepSeek Harness 宿主组合网关插件 | 提供 HTTP 网关(`/api/dsh-bridge/*`)、WS 推送、原生工具;管理编码/评审子代理,并把任务投递进已有会话。**必须装进 harness 宿主组合**(见下文) |
|
|
65
|
+
| `harness/session-target.mjs` | 会话目标的纯决策层 | 目标判定、候选挑选(0/1/N 不猜)、RemoteError→稳定 reason、基线切分、汇报选文。无 DSH import,可单测;`dsh-bridge.mjs` 依赖它,**两个文件必须一起部署** |
|
|
66
|
+
| `test/` | 25 项 Node 测试 | `session-target` 纯函数单测 + `dsh-bridge` 路由级回归测试(假 DSH 运行时加载真实 bridge)。CI 每次都跑 |
|
|
67
|
+
| `templates/codex-mcp/` | 反向通道配置模板 | 把 Codex CLI 当 MCP 服务端挂进 DSH(得到 `mcp__codex__*` 工具),让 DSH 侧也能调用 Codex。见下文「反向通道」 |
|
|
52
68
|
| `cordis.patch.yml` | bundle patch | 把网关插件挂进 DSH 宿主组合;`dsh.bundle.patch` 指向它,所以 `dsh plugin add` 能自动 reconcile |
|
|
53
69
|
|
|
54
70
|
## 功能
|
|
@@ -69,7 +85,8 @@ flowchart LR
|
|
|
69
85
|
|
|
70
86
|
```sh
|
|
71
87
|
# 1) 网关插件装进 harness profile(自动 reconcile bundles,无需手工复制文件)
|
|
72
|
-
dsh plugin --profile
|
|
88
|
+
dsh plugin --profile desktop add @whaletalk/dsh-codex-collab
|
|
89
|
+
# ↑ DSH 客户端用的就是 desktop profile;纯 Web 部署换成 --profile web
|
|
73
90
|
|
|
74
91
|
# 2) MCP 服务器交给 Codex
|
|
75
92
|
npm install -g @whaletalk/dsh-codex-collab
|
|
@@ -81,9 +98,27 @@ npm install -g @whaletalk/dsh-codex-collab
|
|
|
81
98
|
[mcp_servers.dsh]
|
|
82
99
|
command = 'dsh-mcp'
|
|
83
100
|
startup_timeout_sec = 120
|
|
101
|
+
env = { DSH_BRIDGE_URL = "http://127.0.0.1:3080" } # 见下方"端口";不写则默认 3080
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**端口**:网关挂在 profile 的 `webServer` 上,地址取决于 profile 怎么起的——独立 harness 通常 `3080`,而 DSH 客户端里就是 **GUI 自己的端口**(例如 `http://127.0.0.1:19387`)。MCP 与 CLI 都用 `DSH_BRIDGE_URL` 找网关,指错只会看到连接失败或 401。验证:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
curl http://127.0.0.1:3080/api/dsh-bridge/status
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
装完**必须重启 profile 进程**——`dsh plugin add` 改的是 bundles 列表与 node_modules,HMR 不监控这两处;**替换包内容也不会热重载**(ESM 缓存),只有重启进程才加载新版本。
|
|
111
|
+
|
|
112
|
+
### 升级到新版本(有坑,照做)
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
# 1) 带精确版本号安装:不带版本号时 lockfile 会把旧版按回来
|
|
116
|
+
dsh plugin --profile desktop add @whaletalk/dsh-codex-collab@<新版本>
|
|
117
|
+
# 2) 重启 profile 进程:不重启不会加载新的 JS 模块
|
|
84
118
|
```
|
|
85
119
|
|
|
86
|
-
|
|
120
|
+
- **不要用"卸载 → 重装"**:卸载会把这个包从 `dsh.profile.bundles` 移除;重装后若没被重新选中,插件根本不会挂载(表现:所有 `/api/dsh-bridge/*` 都落到网关鉴权层返回 **401**)。真遇到了就在客户端插件页把它的开关打开。
|
|
121
|
+
- **刚发布的版本约 24 小时内装不上**:profile 的 pnpm 带 `minimumReleaseAge` 供应链冷却(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`)。管理器会把它写进 `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 才放行;手工安装可临时加 `--config.minimumReleaseAge=0`。
|
|
87
122
|
|
|
88
123
|
> 本包只提供 bundle patch,不含 profile 之外的强依赖:`@deepseek-ai/dsh-tools` 是 optional peer,由 DSH 安装树在运行时提供。因此 `npm install` 不会去 registry 拉它,也不会因它缺失而报错。
|
|
89
124
|
|
|
@@ -92,21 +127,23 @@ startup_timeout_sec = 120
|
|
|
92
127
|
|
|
93
128
|
### 1. DeepSeek Harness 网关(必需,后端)
|
|
94
129
|
|
|
95
|
-
把
|
|
130
|
+
把 **`harness/` 整个目录**复制到你的 dsh profile 目录(例如 `$DSH_HOME/profiles/web/` 下的 `harness/`),并在该目录的 `cordis.patch.yml` 中追加:
|
|
96
131
|
|
|
97
132
|
```yaml
|
|
98
133
|
- insert:
|
|
99
134
|
- id: dsh-bridge
|
|
100
|
-
name: './dsh-bridge.mjs'
|
|
135
|
+
name: './harness/dsh-bridge.mjs'
|
|
101
136
|
```
|
|
102
137
|
|
|
138
|
+
> `dsh-bridge.mjs` 会 `import './session-target.mjs'`——**两个文件必须在一起**,只复制前者会直接加载失败。
|
|
139
|
+
|
|
103
140
|
重启 harness。默认共享工作目录为 `D:\Harness`,可用环境变量 `DSH_BRIDGE_WORKSPACE` 覆盖。验证:
|
|
104
141
|
|
|
105
142
|
```
|
|
106
143
|
GET http://127.0.0.1:3080/api/dsh-bridge/status
|
|
107
144
|
```
|
|
108
145
|
|
|
109
|
-
>
|
|
146
|
+
> 手工方式必须用相对路径(文件就在 profile 目录里);npm 方式则用包子路径 `@whaletalk/dsh-codex-collab/bridge`,两者不要混用。
|
|
110
147
|
|
|
111
148
|
### 2. Codex 侧(二选一或都用)
|
|
112
149
|
|
|
@@ -158,16 +195,32 @@ tunnel-client run --profile dsh
|
|
|
158
195
|
|
|
159
196
|
**Codex 工作区会话**(MCP 工具就绪后):
|
|
160
197
|
|
|
161
|
-
>
|
|
198
|
+
> **新建子代理**:用 dsh_task 让 DeepSeek 生成一个随机密码 CLI + 测试 + README,放在当前项目目录,完成后你用 dsh_read_file 验收,再 dsh_review 评审
|
|
199
|
+
>
|
|
200
|
+
> **接进原对话**:先用 dsh_sessions 按标题找到用户正在用的那条会话,再用 dsh_task 带 sessionId 把下一步指令投递进去(不要新建会话)
|
|
201
|
+
|
|
202
|
+
**MCP 工具**(Codex 侧首选,工具名带 `mcp__dsh__` 前缀):
|
|
203
|
+
|
|
204
|
+
```jsonc
|
|
205
|
+
// 1) 找到会话(只读)
|
|
206
|
+
dsh_sessions({ "query": "按文档启动OKX策略实验计划" })
|
|
207
|
+
// → { "items": [{ "sessionId": "session-8d481ad9-…", "snippet": "按文档启动OKX策略实验计划",
|
|
208
|
+
// "agentAvailable": true }], "matchedBy": "list" }
|
|
209
|
+
|
|
210
|
+
// 2) 投递进那条会话
|
|
211
|
+
dsh_task({ "instruction": "继续推进第 3 步…", "sessionId": "session-8d481ad9-…" })
|
|
212
|
+
// deliver 可选 "queue"(默认,排队)或 "steer"(插入当前回合)
|
|
213
|
+
```
|
|
162
214
|
|
|
163
215
|
**命令行**(npm 安装后直接用;源码方式用 `node <脚本路径>`):
|
|
164
216
|
|
|
165
217
|
```bash
|
|
166
|
-
#
|
|
218
|
+
# 新建子代理(默认行为)
|
|
167
219
|
dsh-task --in "<指令>" --cwd "<目录>" [--lane backend] [--model fast|pro] [--commit]
|
|
168
220
|
|
|
169
|
-
#
|
|
221
|
+
# 接进已有会话:先找(或先空跑验证),再派
|
|
170
222
|
dsh-task --sessions "<标题关键词>" # 只读列候选,拿 sessionId
|
|
223
|
+
dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中,不投递任何消息
|
|
171
224
|
dsh-task --in "<指令>" --session "<sessionId>" [--steer]
|
|
172
225
|
dsh-task --in "<指令>" --find "<标题关键词>" # 唯一命中才派;否则退出码 4
|
|
173
226
|
|
|
@@ -180,19 +233,82 @@ dsh-task --list | --status <taskId> | --cancel <taskId> [--force]
|
|
|
180
233
|
```bash
|
|
181
234
|
# 1) 找到(只读,不创建任何东西)
|
|
182
235
|
dsh-task --sessions "按文档启动OKX策略实验计划"
|
|
183
|
-
# → {"items":[{"sessionId":"session-8d481ad9
|
|
236
|
+
# → {"items":[{"sessionId":"session-8d481ad9-…","snippet":"按文档启动OKX策略实验计划",
|
|
237
|
+
# "agentAvailable":true}],"total":1,"matchedBy":"list"}
|
|
238
|
+
|
|
239
|
+
# 1b) 没命中时会给近似候选(按标题相似度 + 最近更新排序),换词重查或改用 sessionId——
|
|
240
|
+
# 绝不要发一条消息去"试探命中"(它会落进对方会话并跑成回合,污染对话)
|
|
241
|
+
dsh-task --dry-run --find "量化框架"
|
|
242
|
+
# → {"resolved":false,"reason":"session-query-empty",
|
|
243
|
+
# "candidates":[{"sessionId":"1c0f99f8-…","snippet":"你是一个量化研究项目的技术","score":0.333}, …]}
|
|
244
|
+
|
|
245
|
+
# 2) 接通(投递进那条会话;这一轮的助手回复作为汇报返回,只回报本轮新增文本)
|
|
246
|
+
dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-…"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`matchedBy` 说明"找到"走了哪条路:`search` = 会话搜索索引;`list` = 索引被部署禁用时退回遍历列表 + 标题投影(都不激活 Agent)。
|
|
184
250
|
|
|
185
|
-
|
|
186
|
-
|
|
251
|
+
**`delivered` 与排队语义**(重要):`prompt` 一旦被目标会话接受,就表示消息**已经进了它的队列**,响应里会带 `delivered: true`;此后即便我们的观察超时(会话可能正跑一个很长的回合),也**不要重发**——重发会让同一份内容在用户会话里出现两次。`queue` 模式不再用 120s 判失败:没在 120s 内开跑只记一条 note 并继续等,最终由总预算(默认 1h)收口为 `session-timeout` + `delivered: true`。
|
|
252
|
+
|
|
253
|
+
失败返回稳定 `reason` 且**不创建任何会话/工作区**:`session-query-empty`(没命中,附 `candidates`)、`session-ambiguous`(多条命中,附 `candidates`)、`session-not-found`、`session-busy`、`session-writer-held`(会话被别的写入方占用)、`session-archived`、`session-not-accepted`(仅当 prompt **未被接受**)、`session-timeout`、`session-cancel-refused`(取消会话目标默认被拒,需 `--force`)。
|
|
254
|
+
|
|
255
|
+
## 反向通道:让 DSH 调用 Codex
|
|
256
|
+
|
|
257
|
+
上面的链路都是 **Codex → DSH**。反过来的 **DSH → Codex** 也能做,但要分清两种含义:
|
|
258
|
+
|
|
259
|
+
| 含义 | 可行性 |
|
|
260
|
+
|---|---|
|
|
261
|
+
| 让 DSH 调用 Codex(把 Codex 当执行器/评审员) | ✅ 两端原生支持,见下 |
|
|
262
|
+
| 让 DSH 往你**正开着的某条 ChatGPT/Codex 对话**里插一条消息、就地开一轮 | ❌ 做不到 |
|
|
263
|
+
|
|
264
|
+
第二种做不到的原因:现有链路是 Codex 主动(它启动 `dsh-mcp` 子进程调工具),而 MCP 是
|
|
265
|
+
**客户端发起**的协议——服务端无法命令客户端开一轮。服务端→客户端只有 `notifications/*`
|
|
266
|
+
(纯通知,不触发回合)、`sampling/createMessage`、`elicitation/create`(取决于客户端是否
|
|
267
|
+
实现,且不等于"在对话里跑工具");ChatGPT 侧同样没有公开 API 能向用户会话投消息。
|
|
268
|
+
|
|
269
|
+
### 方案 A:反向通道已内置在本包里(推荐)
|
|
270
|
+
|
|
271
|
+
**装本包就同时得到两个方向**——`cordis.patch.yml` 里插了两行:`dsh-bridge`(Codex → DSH 派活)与 `codex-mcp`(DSH → Codex 反向调用)。原理是两端各有一个原生件:
|
|
272
|
+
|
|
273
|
+
- DSH 侧 `@deepseek-ai/dsh-mcp-client`(harness 自带):连外部 MCP 服务器,并把工具以 `mcp__<serverName>__<tool>` 注册进 `ctx.tools`;
|
|
274
|
+
- Codex 侧 `codex mcp-server`(stdio):把 Codex 暴露成 MCP 服务端。
|
|
275
|
+
|
|
276
|
+
内置的那一行等价于:
|
|
277
|
+
|
|
278
|
+
```yaml
|
|
279
|
+
- insert:
|
|
280
|
+
- id: codex-mcp
|
|
281
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
282
|
+
config:
|
|
283
|
+
serverName: codex
|
|
284
|
+
transport: stdio
|
|
285
|
+
command: codex # 跨平台:SDK 用 cross-spawn,Windows 的 .cmd 垫片也能解析
|
|
286
|
+
args: ['mcp-server']
|
|
287
|
+
failOnStartupError: false # 没装/没登录 Codex 只是没这批工具,不会拖垮 harness
|
|
288
|
+
toolCallTimeoutMs: 600000 # 默认只有 60s,Codex 跑一轮常常不够
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- 前提:本机装了 Codex CLI 并已登录(`codex --version` / `codex mcp-server --help` 能跑)。
|
|
292
|
+
- **不需要反向通道**?在 profile 的 `cordis.patch.yml` 里覆盖一行即可关掉:`- id: codex-mcp` + `disabled: true`。
|
|
293
|
+
- 想单独用反向通道(不装本插件主体)可以用独立模板 [`templates/codex-mcp/`](templates/codex-mcp/README.md)。
|
|
294
|
+
- 装完需**重启 profile 进程**;连接失败只是这批工具不出现(日志里有原因)。
|
|
295
|
+
- 排查:若 `codex` 不在 DSH 进程的 PATH 上,把 `command` 换成 `node` + `args: ['<...>/@openai/codex/bin/codex.js', 'mcp-server']`,或直接指向平台 `codex.exe`。
|
|
296
|
+
|
|
297
|
+
### 方案 B:worker 里直接跑 `codex exec`
|
|
298
|
+
|
|
299
|
+
```powershell
|
|
300
|
+
codex exec "把刚才的 diff 审一遍,只回结论" --cd E:\proj
|
|
187
301
|
```
|
|
188
302
|
|
|
189
|
-
|
|
303
|
+
一次性、零协议改动;代价是每次都是新会话(无跨轮记忆),且需要执行 shell 的权限。
|
|
304
|
+
|
|
305
|
+
> 注意:插件自带的 `dsh_collab_send` / `dsh_collab_review` 是 **DSH 侧入口**,只回到同一个网关,**不经过 Codex**。要"叫 Codex 干活"用上面 A/B;要"让 Codex 那条对话继续",只能等 Codex 自己下一轮(`dsh_task --wait` 或 `dsh_task_status`)。
|
|
190
306
|
|
|
191
307
|
## 环境变量
|
|
192
308
|
|
|
193
309
|
| 变量 | 默认 | 说明 |
|
|
194
310
|
|---|---|---|
|
|
195
|
-
| `DSH_BRIDGE_URL` | `http://127.0.0.1:3080` |
|
|
311
|
+
| `DSH_BRIDGE_URL` | `http://127.0.0.1:3080` | 网关地址。**必须指向插件实际挂载的那个 profile 的端口**(DSH 客户端里就是 GUI 端口,例如 19387) |
|
|
196
312
|
| `DSH_BRIDGE_WORKSPACE` | `D:\Harness` | 网关默认共享工作目录 |
|
|
197
313
|
| `DSH_BRIDGE_ROOT` | `D:\Harness` | `dsh_read_file` 相对路径基准 |
|
|
198
314
|
| `DSH_BRIDGE_HISTORY` | 多级降级 | 协作历史文件位置(默认 D 盘 → 用户目录 → 临时目录逐级降级) |
|
|
@@ -211,12 +327,26 @@ dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-..."
|
|
|
211
327
|
- **评审不支持会话目标**:评审员必须与编码会话隔离,"独立评审"才成立
|
|
212
328
|
- session 目标不使用 `cwd`/`lane`/`model`/`--commit`(会话自带这些语义),传了会被判 `invalid-target`
|
|
213
329
|
- 网关重启会丢失内存中的任务表(含 session 观察器),这是既有设计
|
|
330
|
+
- **替换包内容不会热重载**:升级后必须重启 profile 进程,否则跑的还是旧 JS 模块(HMR 只重挂组合,不复用新代码)
|
|
331
|
+
- **组合包被取消选中 = 插件完全不挂载**:此时所有 `/api/dsh-bridge/*` 都落到网关鉴权层返回 401(不是 404,容易误判成没装)
|
|
214
332
|
- **DSH profile 的 pnpm 供应链冷却**:新发布的版本约 24 小时内会被 `minimumReleaseAge` 拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`);管理器会把它写进 `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 才放行。刚发版就装不上属正常,不是包的问题
|
|
215
|
-
-
|
|
333
|
+
- 升级已装组合包:客户端插件页没有升级入口,官方姿势是**卸载 → 重装**;但卸载会把该包从 `dsh.profile.bundles` 移除,重装后**要确认它被重新选中**,否则插件不会挂载(表现为所有 `/api/dsh-bridge/*` 都落到网关鉴权层返回 401)。更省事的做法是直接 `dsh plugin add <包>@<版本>`,见上文「升级到新版本」
|
|
216
334
|
- Codex Desktop Windows 本地市场技能注入 bug(见上,MCP 通道不受影响)
|
|
217
335
|
- 动态插件环境无 `AbortSignal`,冷恢复失败时自动降级为一次性执行;宿主组合持久化版无此问题
|
|
218
336
|
- ChatGPT Plus 写入型 MCP 的开放度取决于 OpenAI 产品策略
|
|
219
337
|
|
|
338
|
+
## 开发与测试
|
|
339
|
+
|
|
340
|
+
```sh
|
|
341
|
+
node test/session-target.test.mjs # 纯函数:目标判定 / 候选挑选 / 错误映射 / 基线切分 / 汇报选文
|
|
342
|
+
node test/dsh-bridge.test.mjs # 路由级:假 DSH 运行时 + 桩 peer,加载真实 bridge 跑真实路由
|
|
343
|
+
node --test test/*.test.mjs # 两个一起跑(CI 用的是这条)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- 路由级测试会断言 **worker 分支确实调用了 `startContinuable`**、**session 分支只调用 `prompt` 且不建 owner/子代理**——这类"参数遮蔽/串线"缺陷只有在这一层才抓得到(v0.1.2~0.1.4 的 `deliver` 遮蔽 bug 就是它抓回来的)。
|
|
347
|
+
- CI(`.github/workflows/publish.yml`)每次 tag 都跑:语法检查 → 单测 → 包契约 → 版本号三处一致 → tarball 清单 → 干净目录安装 + CLI/MCP 冒烟 → 6 工具握手断言 → bridge 导出契约。**任何一步失败都不会发布**。
|
|
348
|
+
- 发布:`package.json` / `.codex-plugin/plugin.json` / `mcp/dsh-mcp.mjs` 的 `SERVER_VERSION` 三处同步改版本 → commit → 打 tag `vX.Y.Z` → 推 tag,CI 自动带 provenance 发布。
|
|
349
|
+
|
|
220
350
|
## License
|
|
221
351
|
|
|
222
352
|
MIT
|
|
@@ -30,10 +30,17 @@ description: "与 DeepSeek Harness 编码协作。当用户要写代码、建项
|
|
|
30
30
|
## 找到已有会话(只读,不创建任何东西)
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
dsh-task --sessions "<标题或内容关键词>"
|
|
33
|
+
dsh-task --sessions "<标题或内容关键词>" # 只读列出候选
|
|
34
|
+
dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中结果,不投递
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id
|
|
37
|
+
输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id>`。
|
|
38
|
+
|
|
39
|
+
没命中时会额外给出 `candidates`(按标题相似度 + 最近更新排序)——**换词重查,或直接确认用户指的是哪一条**。
|
|
40
|
+
|
|
41
|
+
> ⛔ **绝不投递任何消息来"验证命中"**(例如发一条「占位/测试/忽略本条」)。真实事故:调用方搜不到标题后发了条占位消息试探,它解析成了用户自己的会话,于是**作为一条真实用户消息落进对话并跑成回合**,会话里的 agent 还以为那是用户发的。验证命中只有两种合法手段:上面的 `--sessions` 和 `--dry-run`。
|
|
42
|
+
>
|
|
43
|
+
> ⛔ **看到 `delivered: true` 就不要重发**。它表示 prompt 已被那条会话接受、进了它的队列;我们只是没等到它开跑(会话可能正忙着一个很长的回合)。重发会让同一份内容在用户会话里出现两次。用 `--status <taskId>` 继续观察。
|
|
37
44
|
|
|
38
45
|
## 下发任务
|
|
39
46
|
|
|
@@ -41,6 +41,7 @@ function parseArgs(argv) {
|
|
|
41
41
|
else if (a === '--session') { out.sessionId = argv[++i] || '' }
|
|
42
42
|
else if (a === '--find') { out.find = argv[++i] || '' }
|
|
43
43
|
else if (a === '--steer') { out.steer = true }
|
|
44
|
+
else if (a === '--dry-run') { out.dryRun = true }
|
|
44
45
|
else if (a === '--force') { out.force = true }
|
|
45
46
|
else { rest.push(a) }
|
|
46
47
|
}
|
|
@@ -175,22 +176,53 @@ async function main() {
|
|
|
175
176
|
}
|
|
176
177
|
console.log(JSON.stringify(res.json, null, 2))
|
|
177
178
|
const items = Array.isArray(res.json.items) ? res.json.items : []
|
|
179
|
+
if (items.length === 0 && Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
|
|
180
|
+
console.error('未命中。候选(改用 --session <id>,或换个关键词):')
|
|
181
|
+
for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
|
|
182
|
+
console.error('提示: 验证命中用 --dry-run,不要投递消息去试探。')
|
|
183
|
+
}
|
|
178
184
|
finish(items.length > 0 ? 0 : 4)
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
const instruction = opts.instruction.trim()
|
|
188
|
+
const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
|
|
189
|
+
const sessionId = (opts.sessionId || '').trim()
|
|
190
|
+
const find = (opts.find || '').trim()
|
|
191
|
+
const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
|
|
192
|
+
|
|
193
|
+
// --dry-run:只解析目标、回显命中结果,什么都不投递。验证"这条查询会命中哪条会话"就用它,
|
|
194
|
+
// 绝不要投递一条消息去试探(那会落进用户会话并跑成回合,污染对话)。
|
|
195
|
+
if (opts.dryRun) {
|
|
196
|
+
if (instruction === '' && sessionTarget === null && cwd === '') {
|
|
197
|
+
console.error('用法: node task.mjs --dry-run (--find "<标题关键词>" | --session <会话id> | --cwd <目录>)')
|
|
198
|
+
finish(2)
|
|
199
|
+
}
|
|
200
|
+
const res = await request('POST', GATEWAY + '/api/dsh-bridge/task', {
|
|
201
|
+
instruction: instruction === '' ? '(dry-run)' : instruction,
|
|
202
|
+
dryRun: true,
|
|
203
|
+
...(sessionTarget === null ? (cwd ? { cwd } : {}) : sessionTarget),
|
|
204
|
+
})
|
|
205
|
+
console.log(JSON.stringify(res.json, null, 2))
|
|
206
|
+
if (res.json === null || typeof res.json !== 'object') finish(1)
|
|
207
|
+
if (res.json.resolved !== true) {
|
|
208
|
+
if (Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
|
|
209
|
+
console.error('未命中。候选(改用 --session <id>,或换个关键词):')
|
|
210
|
+
for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
|
|
211
|
+
}
|
|
212
|
+
finish(4)
|
|
213
|
+
}
|
|
214
|
+
finish(0)
|
|
215
|
+
}
|
|
216
|
+
|
|
182
217
|
if (!instruction) {
|
|
183
218
|
console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait]')
|
|
184
219
|
console.error(' node task.mjs --in "<指令>" (--session <会话id> | --find "<标题关键词>") [--steer] # 投递进已有会话')
|
|
185
220
|
console.error('查找: node task.mjs --sessions "<标题关键词>" # 只读列出候选, 拿 sessionId 再派活')
|
|
221
|
+
console.error(' node task.mjs --dry-run (--find "<关键词>" | --session <id>) # 只解析目标, 不投递(验证命中用这个)')
|
|
186
222
|
console.error('管理: node task.mjs --list | --status <taskId> | --cancel <taskId> [--force]')
|
|
187
223
|
console.error('退出码: 0 成功 / 1 失败 / 2 用法 / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒')
|
|
188
224
|
finish(2)
|
|
189
225
|
}
|
|
190
|
-
const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
|
|
191
|
-
const sessionId = (opts.sessionId || '').trim()
|
|
192
|
-
const find = (opts.find || '').trim()
|
|
193
|
-
const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
|
|
194
226
|
if (sessionTarget !== null && (cwd !== '' || opts.commit)) {
|
|
195
227
|
console.error('task.mjs: --session/--find 不能与 --cwd/--commit 同时使用(已有会话自带工作目录,本工具不替它提交)')
|
|
196
228
|
finish(2)
|
|
@@ -246,6 +278,11 @@ async function main() {
|
|
|
246
278
|
console.log(JSON.stringify({ phase: 'finished', ...status }))
|
|
247
279
|
console.log('=== DeepSeek 汇报 ===')
|
|
248
280
|
console.log(status.result || status.error || '(无内容)')
|
|
281
|
+
// delivered=true 表示 prompt 已被那条会话接受(进了它的队列):即便我们等超时了,
|
|
282
|
+
// 消息也可能会跑,**不要重发**(重发会让同一份内容在用户会话里出现两次)。
|
|
283
|
+
if (status.status === 'error' && status.delivered === true) {
|
|
284
|
+
console.error('注意: 消息已投递进该会话的队列(delivered=true),只是我们没等到它开跑。不要重发;可用 --status ' + task.taskId + ' 继续观察。')
|
|
285
|
+
}
|
|
249
286
|
if (status.status === 'done') {
|
|
250
287
|
// session 目标的记忆就在那条会话里,不再往协作历史里塞一份。
|
|
251
288
|
if (sessionTarget === null) {
|
package/cordis.patch.yml
CHANGED
|
@@ -1,20 +1,45 @@
|
|
|
1
|
-
# DSH Collab bundle layer
|
|
1
|
+
# DSH Collab bundle layer —— 双向协作:Codex → DSH 派活 + DSH → Codex 反向调用。
|
|
2
2
|
#
|
|
3
3
|
# 安装(npm):
|
|
4
|
-
# dsh plugin --profile
|
|
5
|
-
# `dsh plugin add` 会把本包加入 profile 的 dsh.profile.bundles,并自动套用本
|
|
6
|
-
# patch,无需手工复制 dsh-bridge.mjs。
|
|
4
|
+
# dsh plugin --profile desktop add @whaletalk/dsh-codex-collab
|
|
5
|
+
# `dsh plugin add` 会把本包加入 profile 的 dsh.profile.bundles,并自动套用本 patch。
|
|
7
6
|
#
|
|
8
|
-
#
|
|
9
|
-
# - id dsh-bridge —— 行 id。用户的 patch 层可用同 id 覆盖整段 config。
|
|
10
|
-
# - name用包子路径而非相对路径(对比 './dsh-bridge.mjs'):只有这样从 npm 安装
|
|
11
|
-
# 后解析器才能定位到包内文件,同时保持 profile 目录干净。
|
|
7
|
+
# 本 bundle 插两行:
|
|
12
8
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# profile
|
|
17
|
-
#
|
|
9
|
+
# 1) dsh-bridge —— 宿主网关插件(Codex → DSH 的派活通道)
|
|
10
|
+
# - id 行 id;用户的 patch 层可用同 id 覆盖整段 config。
|
|
11
|
+
# - name 用包子路径而非相对路径(对比 './dsh-bridge.mjs'):只有这样从 npm 安装
|
|
12
|
+
# 后解析器才能定位到包内文件,同时保持 profile 目录干净。
|
|
13
|
+
# 依赖的服务:webServer / agents / subagents / tools 四个注入,加上运行时取用的
|
|
14
|
+
# timer、sessionQuery、agentPresets、sessionPersistence、workspaceRegistry、llm。
|
|
15
|
+
# 其中 webServer 由 dsh-web-app 提供 —— 因此本 bundle 只能挂 web / desktop
|
|
16
|
+
# profile,headless profile 没有该服务会一直 pending。
|
|
17
|
+
# 本插件不使用 workflowEngine,所以 web GUI 默认禁用 workflow 引擎不影响它。
|
|
18
|
+
#
|
|
19
|
+
# 2) codex-mcp —— 反向通道(DSH → Codex)
|
|
20
|
+
# 把 Codex CLI 当 MCP 服务端挂进来:DSH 侧 agent 会拿到 mcp__codex__* 工具,
|
|
21
|
+
# 可以把活交回 Codex。两端都是现成的(DSH 自带 @deepseek-ai/dsh-mcp-client,
|
|
22
|
+
# Codex 自带 `codex mcp-server`),这里只是配置。
|
|
23
|
+
#
|
|
24
|
+
# - command 写裸名 `codex` 即可跨平台:MCP SDK 的 stdio 用 cross-spawn
|
|
25
|
+
# (shell:false)spawn,它在 Windows 上会正确解析 npm 的 .cmd 垫片。
|
|
26
|
+
# 若你的 codex 不在 PATH 上,改成 `node` + args 指向
|
|
27
|
+
# `...\@openai\codex\bin\codex.js`,或直接指向平台 codex.exe。
|
|
28
|
+
# - toolCallTimeoutMs 默认只有 60s,Codex 跑一轮读仓库常常不够。
|
|
29
|
+
# - failOnStartupError:false:没装/没登录 Codex 时只是这批工具不出现并记一条
|
|
30
|
+
# 日志,不会拖垮 harness。
|
|
31
|
+
# - 不需要反向通道?在 profile 的 cordis.patch.yml 里加一行覆盖即可关掉:
|
|
32
|
+
# - id: codex-mcp
|
|
33
|
+
# disabled: true
|
|
18
34
|
- insert:
|
|
19
35
|
- id: dsh-bridge
|
|
20
|
-
name: '@whaletalk/dsh-codex-collab/bridge'
|
|
36
|
+
name: '@whaletalk/dsh-codex-collab/bridge'
|
|
37
|
+
- id: codex-mcp
|
|
38
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
39
|
+
config:
|
|
40
|
+
serverName: codex
|
|
41
|
+
transport: stdio
|
|
42
|
+
command: codex
|
|
43
|
+
args: ['mcp-server']
|
|
44
|
+
failOnStartupError: false
|
|
45
|
+
toolCallTimeoutMs: 600000
|
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
mapControllerError,
|
|
33
33
|
sessionTitleOf,
|
|
34
34
|
looksLikeDisabledSearch,
|
|
35
|
+
rankTitleCandidates,
|
|
35
36
|
taskResponse,
|
|
36
37
|
} from './session-target.mjs'
|
|
37
38
|
|
|
@@ -449,6 +450,33 @@ export async function apply(ctx) {
|
|
|
449
450
|
}
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
/**
|
|
454
|
+
* 查询没命中时给的"近似候选":读 list(不激活 Agent、不开索引),按 bigram 相似度
|
|
455
|
+
* 与最近更新排序。存在的意义是**不给"探测"留动机**——真实事故里调用方搜不到标题后
|
|
456
|
+
* 发了一条占位消息去试探,那条消息落进用户会话并跑成回合。有了候选,只读就能改词重查。
|
|
457
|
+
*/
|
|
458
|
+
const sessionNearMisses = async (query, limit = 5) => {
|
|
459
|
+
const controller = sessionControllerNow()
|
|
460
|
+
if (controller === undefined) return []
|
|
461
|
+
try {
|
|
462
|
+
const value = await controller.list({}, signalOf())
|
|
463
|
+
const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
|
|
464
|
+
const summaries = []
|
|
465
|
+
for (const summary of items) {
|
|
466
|
+
if (summary === null || typeof summary !== 'object' || typeof summary.sessionId !== 'string') continue
|
|
467
|
+
summaries.push({
|
|
468
|
+
sessionId: summary.sessionId,
|
|
469
|
+
title: sessionTitleOf(summary),
|
|
470
|
+
updatedAt: typeof summary.updatedAt === 'number' ? summary.updatedAt : 0,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
return rankTitleCandidates(query, summaries, limit)
|
|
474
|
+
} catch (e) {
|
|
475
|
+
console.error('[dsh-bridge] near-miss lookup failed:', errText(e).slice(0, 200))
|
|
476
|
+
return []
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
452
480
|
/** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
|
|
453
481
|
const resolveSessionTarget = async (target) => {
|
|
454
482
|
const controller = sessionControllerNow()
|
|
@@ -467,7 +495,13 @@ export async function apply(ctx) {
|
|
|
467
495
|
}
|
|
468
496
|
}
|
|
469
497
|
const picked = selectSession(hits, target)
|
|
470
|
-
if (!picked.ok)
|
|
498
|
+
if (!picked.ok) {
|
|
499
|
+
// 查询落空不是"死路":附上近似候选,调用方只读就能改词重查(别再发消息探测)。
|
|
500
|
+
if (picked.reason === REASONS.QUERY_EMPTY) {
|
|
501
|
+
return { ...picked, candidates: await sessionNearMisses(target.query) }
|
|
502
|
+
}
|
|
503
|
+
return picked
|
|
504
|
+
}
|
|
471
505
|
let result
|
|
472
506
|
try {
|
|
473
507
|
result = await controller.resolveAgent(picked.sessionId)
|
|
@@ -494,6 +528,7 @@ export async function apply(ctx) {
|
|
|
494
528
|
if (fields.error !== undefined) task.error = fields.error
|
|
495
529
|
if (fields.reason !== undefined) task.reason = fields.reason
|
|
496
530
|
if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
|
|
531
|
+
if (fields.delivered !== undefined) task.delivered = fields.delivered
|
|
497
532
|
pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
|
|
498
533
|
...(task.result === undefined ? {} : { text: task.result }),
|
|
499
534
|
...(task.error === undefined ? {} : { error: task.error }),
|
|
@@ -603,6 +638,8 @@ export async function apply(ctx) {
|
|
|
603
638
|
sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
|
|
604
639
|
total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
|
|
605
640
|
settled: false, abort: undefined, watcher: 'pending',
|
|
641
|
+
// prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
|
|
642
|
+
delivered: false, queuedNoted: false,
|
|
606
643
|
}
|
|
607
644
|
sessionTargets.set(resolved.sessionId, record)
|
|
608
645
|
|
|
@@ -635,7 +672,9 @@ export async function apply(ctx) {
|
|
|
635
672
|
}
|
|
636
673
|
|
|
637
674
|
watchSessionTask(record)
|
|
638
|
-
|
|
675
|
+
record.delivered = true
|
|
676
|
+
task.delivered = true
|
|
677
|
+
pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet, delivered: true })
|
|
639
678
|
return { task, failed: null }
|
|
640
679
|
}
|
|
641
680
|
|
|
@@ -770,16 +809,39 @@ export async function apply(ctx) {
|
|
|
770
809
|
}
|
|
771
810
|
} catch (e) { /* 读不到就下一轮再试 */ }
|
|
772
811
|
}
|
|
812
|
+
// 派发超时只在"prompt 没被接受"时才算失败。
|
|
813
|
+
// queue 模式下 prompt 被接受就意味着消息已进队列,会在会话空闲时跑;此时报 error
|
|
814
|
+
// 是误报——实测会话正跑一个 38 分钟的回合,排队等待远超 120s 完全正常,而误报会
|
|
815
|
+
// 直接诱导调用方重发(真实事故:同一条汇报被投了两次)。所以这里只记一条 note,
|
|
816
|
+
// 继续等,由下面的总预算兜底。
|
|
773
817
|
if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
|
|
774
|
-
|
|
775
|
-
|
|
818
|
+
if (record.delivered === true) {
|
|
819
|
+
if (record.queuedNoted !== true) {
|
|
820
|
+
record.queuedNoted = true
|
|
821
|
+
pushTask(record.taskId, 'note', {
|
|
822
|
+
note: 'QUEUED',
|
|
823
|
+
text: 'prompt 已被接受并进入该会话队列,等它空闲时会跑;不要重发',
|
|
824
|
+
target: { kind: 'session', sessionId: record.sessionId },
|
|
825
|
+
delivered: true,
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
|
|
830
|
+
continue
|
|
831
|
+
}
|
|
776
832
|
}
|
|
777
833
|
if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
|
|
778
834
|
finishSessionTask(record, 'quiet-timeout')
|
|
779
835
|
continue
|
|
780
836
|
}
|
|
781
837
|
if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
|
|
782
|
-
settleSessionTask(record, 'error', {
|
|
838
|
+
settleSessionTask(record, 'error', {
|
|
839
|
+
error: record.delivered === true
|
|
840
|
+
? 'prompt 已投递进该会话队列,但预算内它始终没有开始这一轮(会话可能一直在忙,或未被激活);不要重发,可用 status 继续观察'
|
|
841
|
+
: 'session task exceeded its total budget before the prompt was accepted',
|
|
842
|
+
reason: REASONS.TIMEOUT,
|
|
843
|
+
delivered: record.delivered === true,
|
|
844
|
+
})
|
|
783
845
|
}
|
|
784
846
|
}
|
|
785
847
|
}
|
|
@@ -851,14 +913,55 @@ export async function apply(ctx) {
|
|
|
851
913
|
const parsed = await readJsonBody(req, res)
|
|
852
914
|
if (parsed === null) return
|
|
853
915
|
const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
|
|
854
|
-
|
|
916
|
+
const dryRun = parsed !== null && typeof parsed === 'object' && parsed.dryRun === true
|
|
917
|
+
if (!dryRun && instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
|
|
855
918
|
const classified = classifyTarget(parsed)
|
|
856
919
|
if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
|
|
920
|
+
// dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
|
|
921
|
+
// 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
|
|
922
|
+
// 消息去探测,结果污染了用户会话。
|
|
923
|
+
if (dryRun) {
|
|
924
|
+
if (classified.target.kind === 'session') {
|
|
925
|
+
const resolved = await resolveSessionTarget(classified.target)
|
|
926
|
+
if (!resolved.ok) {
|
|
927
|
+
sendJson(res, 200, {
|
|
928
|
+
dryRun: true,
|
|
929
|
+
resolved: false,
|
|
930
|
+
reason: resolved.reason,
|
|
931
|
+
error: resolved.message,
|
|
932
|
+
...(Array.isArray(resolved.candidates) && resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
|
|
933
|
+
})
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
sendJson(res, 200, {
|
|
937
|
+
dryRun: true,
|
|
938
|
+
resolved: true,
|
|
939
|
+
wouldDispatch: true,
|
|
940
|
+
target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
|
|
941
|
+
...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
|
|
942
|
+
...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
|
|
943
|
+
...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
|
|
944
|
+
note: '只解析目标,未投递任何消息',
|
|
945
|
+
})
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
const cwd = normalizeCwd(classified.target.cwd)
|
|
949
|
+
const lane = normalizeLane(classified.target.lane)
|
|
950
|
+
sendJson(res, 200, {
|
|
951
|
+
dryRun: true,
|
|
952
|
+
resolved: true,
|
|
953
|
+
wouldDispatch: true,
|
|
954
|
+
target: { kind: 'worker', cwd, lane },
|
|
955
|
+
note: '只解析目标,未派活',
|
|
956
|
+
})
|
|
957
|
+
return
|
|
958
|
+
}
|
|
857
959
|
const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
|
|
858
960
|
if (task.status === 'error') {
|
|
859
961
|
sendJson(res, 400, {
|
|
860
962
|
error: task.error,
|
|
861
963
|
reason: task.reason,
|
|
964
|
+
...(task.delivered === true ? { delivered: true } : {}),
|
|
862
965
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
863
966
|
})
|
|
864
967
|
return
|
|
@@ -939,7 +1042,18 @@ export async function apply(ctx) {
|
|
|
939
1042
|
}
|
|
940
1043
|
}
|
|
941
1044
|
}
|
|
942
|
-
|
|
1045
|
+
// 没命中不是死路:给近似候选(同一批只读数据,按相似度 + 最近更新排序),
|
|
1046
|
+
// 让调用方改词重查——而不是发一条消息去"探测命中"。
|
|
1047
|
+
const candidates = hits.length === 0 ? await sessionNearMisses(query, limit) : []
|
|
1048
|
+
sendJson(res, 200, {
|
|
1049
|
+
items,
|
|
1050
|
+
hasMore: hits.length > items.length,
|
|
1051
|
+
total: hits.length,
|
|
1052
|
+
matchedBy: found.matchedBy,
|
|
1053
|
+
...(candidates.length > 0
|
|
1054
|
+
? { candidates, reason: REASONS.QUERY_EMPTY, note: '标题/目录里都没有这个关键词;candidates 是按相似度与最近更新排的候选,改词重查或直接用 sessionId' }
|
|
1055
|
+
: {}),
|
|
1056
|
+
})
|
|
943
1057
|
} catch (e) {
|
|
944
1058
|
sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
|
|
945
1059
|
}
|
|
@@ -1042,6 +1156,8 @@ export async function apply(ctx) {
|
|
|
1042
1156
|
taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
|
|
1043
1157
|
target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
|
|
1044
1158
|
result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
|
|
1159
|
+
// delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
|
|
1160
|
+
...(task.delivered === undefined ? {} : { delivered: task.delivered }),
|
|
1045
1161
|
...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
|
|
1046
1162
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
1047
1163
|
...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
|
|
@@ -1143,24 +1259,48 @@ export async function apply(ctx) {
|
|
|
1143
1259
|
// ---- 原生工具(宿主全局工具层)----
|
|
1144
1260
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
1145
1261
|
name: 'dsh_collab_send',
|
|
1146
|
-
description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。'
|
|
1262
|
+
description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。'
|
|
1263
|
+
+ '【硬规则】验证"这条查询会命中哪条会话"必须用 dryRun=true(只解析、不投递);**绝不投递任何消息去试探命中**——那会作为一条真实用户消息落进对方会话并跑成回合,污染对话。'
|
|
1264
|
+
+ '【硬规则】返回里 delivered=true 表示消息已被那条会话接受(进了它的队列),即便随后观察到超时或错误也**不要重发**,用 wait=false 记下 taskId 再查状态。'
|
|
1265
|
+
+ 'sessionQuery 落空时会返回 candidates(按标题相似度 + 最近更新排序),换词重查或改用 sessionId。',
|
|
1147
1266
|
parameters: {
|
|
1148
1267
|
instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
|
|
1149
1268
|
cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
|
|
1150
1269
|
lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
|
|
1151
1270
|
model: { type: 'string', description: '模型别名: default | fast | pro。' },
|
|
1152
1271
|
sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
|
|
1153
|
-
sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(
|
|
1272
|
+
sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
|
|
1154
1273
|
deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
|
|
1274
|
+
dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
|
|
1155
1275
|
wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
|
|
1156
1276
|
},
|
|
1157
1277
|
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
|
|
1158
1278
|
async execute(args) {
|
|
1159
1279
|
const classified = classifyTarget(args)
|
|
1160
1280
|
if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
|
|
1281
|
+
// dryRun:只解析目标、回显,不投递。给调用方一条"先验证命中"的正路,免得它发消息试探。
|
|
1282
|
+
if (args.dryRun === true) {
|
|
1283
|
+
if (classified.target.kind === 'session') {
|
|
1284
|
+
const resolved = await resolveSessionTarget(classified.target)
|
|
1285
|
+
if (!resolved.ok) {
|
|
1286
|
+
return { ok: false, dryRun: true, resolved: false, reason: resolved.reason, error: resolved.message, ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}) }
|
|
1287
|
+
}
|
|
1288
|
+
return {
|
|
1289
|
+
ok: true, dryRun: true, resolved: true,
|
|
1290
|
+
target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
|
|
1291
|
+
...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
|
|
1292
|
+
...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
|
|
1293
|
+
...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
|
|
1294
|
+
note: '只解析目标,未投递任何消息',
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const cwd = normalizeCwd(classified.target.cwd)
|
|
1298
|
+
const lane = normalizeLane(classified.target.lane)
|
|
1299
|
+
return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
|
|
1300
|
+
}
|
|
1161
1301
|
const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
|
|
1162
1302
|
if (task.status === 'error') {
|
|
1163
|
-
return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
|
|
1303
|
+
return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
|
|
1164
1304
|
}
|
|
1165
1305
|
const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
|
|
1166
1306
|
if (args.wait !== true || timer === undefined) {
|
|
@@ -1171,7 +1311,7 @@ export async function apply(ctx) {
|
|
|
1171
1311
|
if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
|
|
1172
1312
|
await timer.timeout(2500)
|
|
1173
1313
|
}
|
|
1174
|
-
return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
|
|
1314
|
+
return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason, ...(task.delivered === true ? { delivered: true, resend: false } : {}) }
|
|
1175
1315
|
},
|
|
1176
1316
|
})))
|
|
1177
1317
|
|
|
@@ -232,6 +232,54 @@ export const looksLikeDisabledSearch = (error) => {
|
|
|
232
232
|
return /search is disabled/i.test(text)
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/** 去空白后的字符 bigram 集合(CJK 友好:不需要分词)。 */
|
|
236
|
+
const bigramsOf = (value) => {
|
|
237
|
+
const text = typeof value === 'string' ? value.replace(/\s+/g, '') : ''
|
|
238
|
+
const out = new Set()
|
|
239
|
+
if (text.length === 1) out.add(text)
|
|
240
|
+
for (let i = 0; i + 1 < text.length; i++) out.add(text.slice(i, i + 2))
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 查询里的 bigram 有多少比例出现在标题里(0..1)。 */
|
|
245
|
+
export const titleSimilarity = (query, title) => {
|
|
246
|
+
const q = bigramsOf(query)
|
|
247
|
+
const t = bigramsOf(title)
|
|
248
|
+
if (q.size === 0 || t.size === 0) return 0
|
|
249
|
+
let shared = 0
|
|
250
|
+
for (const gram of q) if (t.has(gram)) shared++
|
|
251
|
+
return shared / q.size
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `session-query-empty` 时给出的"近似候选"。
|
|
256
|
+
*
|
|
257
|
+
* 起因是一个真实事故:调用方搜「量化框架」没命中,又**没有别的办法确认标题**,于是
|
|
258
|
+
* 发了一条占位消息去"探测命中"——那条消息落进了用户自己的会话并跑成回合,污染了
|
|
259
|
+
* 对话。修法是不给"探测"留动机:查询落空时直接回一批候选标题(同 bigram 相似度排
|
|
260
|
+
* 序,无相似度的按最近更新补位),调用方靠只读信息就能改词重查。
|
|
261
|
+
*/
|
|
262
|
+
export const rankTitleCandidates = (query, summaries, limit = 5) => {
|
|
263
|
+
const list = Array.isArray(summaries) ? summaries : []
|
|
264
|
+
const scored = []
|
|
265
|
+
for (const summary of list) {
|
|
266
|
+
if (summary === null || typeof summary !== 'object') continue
|
|
267
|
+
if (typeof summary.sessionId !== 'string' || summary.sessionId === '') continue
|
|
268
|
+
const title = typeof summary.title === 'string' && summary.title !== '' ? summary.title : ''
|
|
269
|
+
const updatedAt = typeof summary.updatedAt === 'number' ? summary.updatedAt : 0
|
|
270
|
+
scored.push({ sessionId: summary.sessionId, snippet: title, score: titleSimilarity(query, title), updatedAt })
|
|
271
|
+
}
|
|
272
|
+
const byRecency = (a, b) => b.updatedAt - a.updatedAt
|
|
273
|
+
const related = scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score || byRecency(a, b))
|
|
274
|
+
const rest = scored.filter((s) => s.score === 0).sort(byRecency)
|
|
275
|
+
const bound = Math.max(1, Math.min(Number(limit) || 5, MAX_CANDIDATES))
|
|
276
|
+
return [...related, ...rest].slice(0, bound).map((s) => ({
|
|
277
|
+
sessionId: s.sessionId,
|
|
278
|
+
snippet: s.snippet,
|
|
279
|
+
score: Math.round(s.score * 1000) / 1000,
|
|
280
|
+
}))
|
|
281
|
+
}
|
|
282
|
+
|
|
235
283
|
/** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
|
|
236
284
|
export const taskResponse = (task) => {
|
|
237
285
|
const out = { taskId: task.taskId, status: task.status, kind: task.kind }
|
|
@@ -246,5 +294,10 @@ export const taskResponse = (task) => {
|
|
|
246
294
|
out.target = { kind: 'worker', cwd: target.cwd, lane: target.lane }
|
|
247
295
|
}
|
|
248
296
|
if (Array.isArray(task.notes) && task.notes.length > 0) out.notes = task.notes
|
|
297
|
+
// delivered=true:prompt 已被目标会话接受(进了它的队列)。调用方据此知道
|
|
298
|
+
// "别重发"——即便随后我们的观察超时并报错,消息也可能正在队列里等着跑。
|
|
299
|
+
if (task.target !== undefined && task.target !== null && task.target.kind === 'session' && task.delivered === true) {
|
|
300
|
+
out.delivered = true
|
|
301
|
+
}
|
|
249
302
|
return out
|
|
250
303
|
}
|
package/mcp/dsh-mcp.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 客户端(Secure MCP Tunnel / cloudflared)桥接给 ChatGPT 等远程产品。
|
|
7
7
|
const GATEWAY = process.env.DSH_BRIDGE_URL || 'http://127.0.0.1:3080'
|
|
8
8
|
const SERVER_NAME = 'dsh-bridge'
|
|
9
|
-
const SERVER_VERSION = '0.1.
|
|
9
|
+
const SERVER_VERSION = '0.1.7'
|
|
10
10
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
11
11
|
|
|
12
12
|
const log = (...args) => console.error('[dsh-mcp]', ...args)
|
|
@@ -20,17 +20,20 @@ const tools = [
|
|
|
20
20
|
'参数: instruction(必填,完整中文指令:目标/产出目录/验收标准/约束), cwd(工作目录,默认 D:\\Harness,通常传当前项目目录), ' +
|
|
21
21
|
'lane(并行通道名,默认 main), model(fast=deepseek-v4-flash 快而省 / pro=deepseek-v4-pro 质量优先), ' +
|
|
22
22
|
'sessionId(已有会话 id), sessionQuery(按标题/内容搜已有会话,需唯一命中,否则返回候选), deliver(queue=排队(默认) / steer=插入当前回合), ' +
|
|
23
|
-
'
|
|
23
|
+
'dryRun(true 时只解析目标并回显, 不投递任何消息), ' +
|
|
24
|
+
'wait(默认 true,阻塞至任务完成并返回 DeepSeek 汇报), timeoutSec(默认 600,最长 900)。' +
|
|
25
|
+
'注意: 验证"这条查询会命中哪条会话"必须用 dryRun 或 dsh_sessions——**绝不要投递一条消息去试探**(那会污染用户会话)。',
|
|
24
26
|
inputSchema: {
|
|
25
27
|
type: 'object',
|
|
26
28
|
properties: {
|
|
27
|
-
instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。' },
|
|
29
|
+
instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。dryRun 时可省略。' },
|
|
28
30
|
cwd: { type: 'string', description: '工作目录(绝对路径)。默认 D:\\Harness。与 sessionId/sessionQuery 互斥。' },
|
|
29
31
|
lane: { type: 'string', description: '并行通道名(默认 main)。' },
|
|
30
32
|
model: { type: 'string', enum: ['fast', 'pro'], description: '模型别名:fast=deepseek-v4-flash,pro=deepseek-v4-pro。' },
|
|
31
33
|
sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建子代理。' },
|
|
32
|
-
sessionQuery: { type: 'string', description: '
|
|
34
|
+
sessionQuery: { type: 'string', description: '按标题或内容搜索已有会话;唯一命中才派发,多条或落空会返回 candidates 让你改用 sessionId。' },
|
|
33
35
|
deliver: { type: 'string', enum: ['queue', 'steer'], description: '投递方式:queue 排队(默认),steer 插入当前回合。' },
|
|
36
|
+
dryRun: { type: 'boolean', description: '只解析目标并回显(含命中路径 matchedBy 与候选),不投递任何消息。用来确认命中,替代"发消息探测"。' },
|
|
34
37
|
wait: { type: 'boolean', description: '默认 true:阻塞直到任务完成并返回汇报。' },
|
|
35
38
|
timeoutSec: { type: 'number', description: '最长等待秒数(默认 600,最大 900)。' },
|
|
36
39
|
},
|
|
@@ -138,12 +141,19 @@ async function handleToolCall(name, args) {
|
|
|
138
141
|
sessionId: args.sessionId,
|
|
139
142
|
sessionQuery: args.sessionQuery,
|
|
140
143
|
deliver: args.deliver,
|
|
144
|
+
dryRun: args.dryRun === true,
|
|
141
145
|
})
|
|
146
|
+
// dryRun 只解析目标,不产生任务:原样回显给你看命中结果。
|
|
147
|
+
if (args.dryRun === true) return { ok: task.resolved === true, phase: 'dry-run', ...task }
|
|
142
148
|
// 4xx 拒绝(缺目标 / 会话不存在 / 搜索结果不唯一 / 被写锁占用)不带 taskId,绝不静默新建。
|
|
143
149
|
if (task.taskId === undefined) return { ok: false, phase: 'rejected', ...task }
|
|
144
150
|
if (task.status === 'error') return { ok: false, ...task }
|
|
145
151
|
if (args.wait === false) return { ok: true, phase: 'submitted', ...task }
|
|
146
152
|
const done = await waitTask(task.taskId, args.timeoutSec || 600)
|
|
153
|
+
// 会话目标报错但 delivered=true:消息已在目标会话队列里,不要重发。
|
|
154
|
+
if (done.delivered === true && done.status === 'error') {
|
|
155
|
+
return { ok: false, phase: 'queued-then-failed', resend: false, ...done }
|
|
156
|
+
}
|
|
147
157
|
return { ok: done.status === 'done', phase: 'finished', ...done }
|
|
148
158
|
}
|
|
149
159
|
if (name === 'dsh_sessions') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whaletalk/dsh-codex-collab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Codex × DeepSeek Harness 双向编码协作:Codex 主导派活与验收,DeepSeek Harness 编码子代理在共享工作目录执行并回传,独立评审子代理实际跑通构建/测试后出具报告。提供 DSH 宿主网关插件、零依赖 MCP 服务器与 Codex 技能三件套。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"harness/",
|
|
24
24
|
"mcp/",
|
|
25
25
|
"codex-plugin/",
|
|
26
|
+
"templates/",
|
|
26
27
|
"cordis.patch.yml",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# templates/codex-mcp —— 反向通道:让 DSH 调用 Codex
|
|
2
|
+
|
|
3
|
+
> **注意:主包已内置这一行。** 装 `@whaletalk/dsh-codex-collab` 时会同时挂上
|
|
4
|
+
> `dsh-bridge`(Codex → DSH 派活)和 `codex-mcp`(DSH → Codex),不需要再单独装。
|
|
5
|
+
> 本目录是**独立版本**:只想用反向通道、或想单独调参时用。关掉内置的那一行只需在
|
|
6
|
+
> profile 的 `cordis.patch.yml` 里覆盖 `- id: codex-mcp` + `disabled: true`。
|
|
7
|
+
|
|
8
|
+
把 **Codex CLI 当作 MCP 服务端**挂进 DeepSeek Harness,于是 DSH 侧的 agent(包括
|
|
9
|
+
本插件派出去的 worker)会拿到 `mcp__codex__*` 工具,可以把活**交回 Codex**。
|
|
10
|
+
|
|
11
|
+
- DSH 侧负责连接的是 harness 自带的 `@deepseek-ai/dsh-mcp-client`(把外部 MCP
|
|
12
|
+
服务器的工具注册进 `ctx.tools`,支持 `stdio` 与 `streamable-http`,带断线重连)。
|
|
13
|
+
- Codex 侧负责暴露的是 `codex mcp-server`(stdio)。
|
|
14
|
+
|
|
15
|
+
## 装法
|
|
16
|
+
|
|
17
|
+
**A. 并进你现有的 profile patch**(推荐,最少文件):把 `cordis.patch.yml` 里那行
|
|
18
|
+
`insert` 追加到 `~/.dsh/profiles/<profile>/cordis.patch.yml` 数组末尾。
|
|
19
|
+
|
|
20
|
+
**B. 当作独立 bundle 安装**:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile desktop add "<本目录绝对路径>"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
两种方式都一样:**装完必须重启 profile 进程**(新增组合包不会热挂载),
|
|
27
|
+
重启后工具才会出现在列表里。
|
|
28
|
+
|
|
29
|
+
## 配置要点
|
|
30
|
+
|
|
31
|
+
| 字段 | 说明 |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `serverName` | 工具命名空间,工具名形如 `mcp__codex__<tool>`;同时挂多个服务器时各自独立 |
|
|
34
|
+
| `transport` | `stdio`(本地进程)或 `streamable-http`(远端) |
|
|
35
|
+
| `command` / `args` / `env` / `cwd` | 仅 stdio:可执行文件、参数、追加环境变量、工作目录 |
|
|
36
|
+
| `failOnStartupError` | `false` = 连不上也照常启动 harness,只是没有这批工具 |
|
|
37
|
+
| `toolCallTimeoutMs` | 每次 `tools/call` 超时;**默认只有 60s**,Codex 跑一轮常常不够,建议 600000 |
|
|
38
|
+
|
|
39
|
+
### `command` 怎么写(Windows 也能用裸名)
|
|
40
|
+
|
|
41
|
+
**默认写裸名 `codex` 即可,跨平台。** DSH 的 MCP 客户端走官方 SDK 的
|
|
42
|
+
`StdioClientTransport`,它内部用 **cross-spawn**(`shell: false`)spawn——cross-spawn
|
|
43
|
+
在 Windows 上会正确解析 npm 的 `.cmd` 垫片。实测:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
cross-spawn('codex', ['--version']) → exit 0, "codex-cli 0.147.0"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
只有当 `codex` 不在 DSH 进程的 PATH 上(或你想完全绕开 npm 垫片)时才需要换写法:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
# 备选 1:node 跑垫片背后的 JS
|
|
53
|
+
command: node
|
|
54
|
+
args: ['C:\Users\<you>\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js', 'mcp-server']
|
|
55
|
+
|
|
56
|
+
# 备选 2:直接指向平台二进制(路径随版本/架构略有不同)
|
|
57
|
+
command: 'C:\Users\<you>\AppData\Roaming\npm\node_modules\@openai\codex\node_modules\@openai\codex-win32-x64\vendor\x86_64-pc-windows-msvc\bin\codex.exe'
|
|
58
|
+
args: ['mcp-server']
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 验证
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
codex --version # 需要 CLI 已登录可用
|
|
65
|
+
codex mcp-server --help # 应打印 "Start Codex as an MCP server (stdio)"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
接好后在 DSH 侧让 agent 调一次 `mcp__codex__*`;工具没出现就去看 harness 日志
|
|
69
|
+
(连接失败会记录原因,且不会中止启动)。
|
|
70
|
+
|
|
71
|
+
## 能做什么、不能做什么
|
|
72
|
+
|
|
73
|
+
| | 可行性 |
|
|
74
|
+
|---|---|
|
|
75
|
+
| 让 DSH 调用 Codex(当执行器/评审员) | ✅ 本目录的做法 |
|
|
76
|
+
| 让 DSH 往你**正开着的某条 ChatGPT/Codex 对话**里插消息、就地开一轮 | ❌ 做不到 |
|
|
77
|
+
|
|
78
|
+
原因:现有链路是 Codex 主动(它启动 `dsh-mcp` 子进程调工具),MCP 是**客户端发起**
|
|
79
|
+
的协议,服务端无法命令客户端开一轮;服务端→客户端只有 `notifications/*`(不触发
|
|
80
|
+
回合)、`sampling/createMessage`、`elicitation/create`(取决于客户端实现,且不等于
|
|
81
|
+
"在对话里跑工具")。ChatGPT 侧同样没有公开 API 能向用户会话投消息。
|
|
82
|
+
|
|
83
|
+
> 另注:本插件自带的 `dsh_collab_send` / `dsh_collab_review` 是 **DSH 侧入口**,
|
|
84
|
+
> 它们只回到同一个网关,**不经过 Codex**。
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 反向通道:把 Codex 挂成 DSH 的一个 MCP 服务端。
|
|
2
|
+
#
|
|
3
|
+
# 谁提供什么:
|
|
4
|
+
# - DSH 侧:@deepseek-ai/dsh-mcp-client(harness 自带)——连外部 MCP 服务器,
|
|
5
|
+
# 并把它们的工具以 mcp__<serverName>__<tool> 注册进 ctx.tools。
|
|
6
|
+
# - Codex 侧:codex mcp-server(stdio)——把 Codex 暴露成 MCP 服务端。
|
|
7
|
+
#
|
|
8
|
+
# 用法(二选一):
|
|
9
|
+
# A) 把下面这行 insert 追加到你的 profile cordis.patch.yml 数组末尾;
|
|
10
|
+
# B) 把本目录当作一个 bundle 安装(路径写绝对路径):
|
|
11
|
+
# dsh plugin --profile <profile> add <本目录绝对路径>
|
|
12
|
+
#
|
|
13
|
+
# 注意:主包 @whaletalk/dsh-codex-collab 已内置等价的一行;本文件是**独立版本**,
|
|
14
|
+
# 用于只想单独挂反向通道、或想单独调参的场景。
|
|
15
|
+
#
|
|
16
|
+
# 装完必须**重启 profile 进程**:新增组合包不会热挂载,只有重启后工具才会出现。
|
|
17
|
+
# 之后在 DSH 侧的工具列表里找 mcp__codex__*(例如 mcp__codex__codex)。
|
|
18
|
+
- insert:
|
|
19
|
+
- id: codex-mcp
|
|
20
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
21
|
+
config:
|
|
22
|
+
serverName: codex
|
|
23
|
+
transport: stdio
|
|
24
|
+
|
|
25
|
+
# 裸名 `codex` 跨平台可用:DSH 的 MCP 客户端用官方 SDK 的
|
|
26
|
+
# StdioClientTransport,内部是 cross-spawn(shell:false),在 Windows 上
|
|
27
|
+
# 也能正确解析 npm 的 .cmd 垫片(本机实测 exit 0)。
|
|
28
|
+
command: codex
|
|
29
|
+
args: ['mcp-server']
|
|
30
|
+
|
|
31
|
+
# 若 codex 不在 DSH 进程的 PATH 上,改成下面任一种:
|
|
32
|
+
# command: node
|
|
33
|
+
# args: ['C:\Users\<you>\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js', 'mcp-server']
|
|
34
|
+
# 或直接指向平台二进制:
|
|
35
|
+
# command: 'C:\Users\<you>\AppData\Roaming\npm\node_modules\@openai\codex\node_modules\@openai\codex-win32-x64\vendor\x86_64-pc-windows-msvc\bin\codex.exe'
|
|
36
|
+
# args: ['mcp-server']
|
|
37
|
+
|
|
38
|
+
# 远端 Codex(有人把 mcp-server 包成 HTTP 时)用:
|
|
39
|
+
# transport: streamable-http
|
|
40
|
+
# url: http://127.0.0.1:3000/mcp
|
|
41
|
+
# headers: { Authorization: 'Bearer ...' }
|
|
42
|
+
|
|
43
|
+
# 启动失败不拖垮 harness:插件仍激活,只是不注册这批工具,日志里有原因。
|
|
44
|
+
failOnStartupError: false
|
|
45
|
+
# Codex 一轮可能跑很久(要读完仓库再答),给足超时;默认只有 60s。
|
|
46
|
+
toolCallTimeoutMs: 600000
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-codex-mcp-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "把 Codex CLI 当作 MCP 服务端挂进 DeepSeek Harness:DSH 侧 agent 获得 mcp__codex__* 工具,可反向调用 Codex。",
|
|
7
|
+
"dsh": {
|
|
8
|
+
"bundle": {
|
|
9
|
+
"patch": "./cordis.patch.yml"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|