dsh-plugin-subagent-bridge 0.15.0 → 0.15.1

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.
Files changed (2) hide show
  1. package/README.md +200 -276
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -5,289 +5,220 @@
5
5
  <!-- M8ven ownership proof — keep this line in place; it verifies the publisher. -->
6
6
  [![M8ven Score](https://m8ven.ai/badge/mcp/dogtiti-dsh-plugin-subagent-bridge-8a3t8k?v=f15f3e56c3fa22e8aafb88cf5428501f)](https://m8ven.ai/mcp/dogtiti-dsh-plugin-subagent-bridge-8a3t8k?s=readme)
7
7
 
8
- 在 DeepSeek Harness 宿主内部提供**本机回环 MCP 端点**,让另一个智能体(如 Codex)把任务派给 DSH 会话并取回结果。
8
+ 在 DeepSeek Harness 宿主内部提供**本机回环 MCP 端点**,让另一个智能体把任务派给 DSH 会话并取回结果;反过来,DSH 里的 agent 也能把任务派给 Codex 或 Claude Code。
9
9
 
10
- 插件运行在宿主进程内,直接使用公开的 `sessionController` 服务 —— 不需要读取 `~/.dsh/.credentials.yaml`,也不需要伪造 cookie。
10
+ 两个方向共用同一个插件,各自遵守对方产品的 `maxDepth: 1`:**能派活,不能再派回来**。
11
11
 
12
12
  ---
13
13
 
14
- ## 让 DSH 派活给 Claude
14
+ ## 能力一览
15
15
 
16
- 反向那一侧以前只有 Codex。现在多了 `claude_dispatch`:DSH 里的 agent 可以把任务交给 Claude Code,拿回最终回答。
17
-
18
- ```js
19
- claude_dispatch({ prompt: "审一下这个 PR 的风险", cwd: "/repo", resume: "a4925654-…", model: "sonnet" })
20
- ```
21
-
22
- | 参数 | 说明 |
23
- | --- | --- |
24
- | `prompt` | 必填,任务正文 |
25
- | `cwd` | 工作目录;默认用当前会话目录 |
26
- | `resume` | Claude Code 的 session id,续接同一段对话 |
27
- | `model` | 模型别名或 id;不传则用配置,再退回 Claude 自己的设置 |
28
-
29
- 返回 `{ ok, text, sessionId, durationMs }`,`sessionId` 就是下次 `resume` 用的那个。
30
-
31
- 和 `codex_dispatch` 的差别只在底层:Claude Code 的 `-p` 模式**一次输出一个 JSON 对象**(`{type:"result", result, session_id, is_error}`),不像 Codex 是事件流。所以解析器只看 stdout 的 JSON,**stderr 的警告不算失败**——比如模型名带 `[1M]` 后缀时 CLI 会警告,但仍然正常返回。
32
-
33
- 配置项:`claudeBin`(默认 `claude`)、`claudeModel`(默认空,即用 CLI 自己的设置)、`claudeTimeoutMs`(默认 300 秒,上限 240 秒夹取)。
34
-
35
- ## 创建项目(workspace)
36
-
37
- DSH 的侧边栏分组是"项目(workspace)",和会话的工作目录是两回事:新建会话默认落在「未分组」,除非它所在的目录已经是一个项目。
38
-
39
- `dsh_task` / `dsh_spawn` 的 `workspace` 参数把这一步也接管了:
40
-
41
- ```js
42
- // 目录不是项目 → 用它创建项目(标题取目录名),会话直接归到项目下
43
- dsh_task({ prompt: "…", cwd: "/repo/clawer", workspace: true })
44
-
45
- // 用指定标题创建
46
- dsh_spawn({ task_name: "w1", message: "…", cwd: "/repo/clawer", workspace: "Clawer" })
47
-
48
- // 不传 → 只复用已有项目;目录没有项目就保持未分组(和以前一样)
49
- dsh_task({ prompt: "…", cwd: "/repo/clawer" })
50
- ```
51
-
52
- 行为细节:
53
-
54
- - 目录不存在时先递归创建,再做项目登记(登记要求目录已存在);
55
- - 目录已经是项目 → **直接复用,不改标题**(底层 `create` 对同一路径返回已有记录);
56
- - 返回结果里带上 `workspace: { id, title, path }`,方便确认落在哪个项目;
57
- - 项目操作失败不影响派发本身(分组是外观,不因此让任务失败)。
16
+ | 方向 | 入口 | 一次调用做什么 |
17
+ | --- | --- | --- |
18
+ | Codex / 任意 MCP 客户端 → DSH | `dsh_task` | 开会话、投递、阻塞到这一轮结束,只回最终答案 |
19
+ | 同上,并行 | `dsh_spawn` + `dsh_wait` | 立刻返回句柄,多个 worker 同时跑,再批量等 |
20
+ | 同上,续派 | `dsh_followup` | 复用 worker 的上下文再派一轮 |
21
+ | DSH → Codex | `codex_dispatch` | 跑一次 `codex exec`,回最终回答与可续接的线程 id |
22
+ | DSH → Claude Code | `claude_dispatch` | 跑一次 `claude -p`,回最终回答与可续接的 session id |
58
23
 
59
- ## 没人值守:让它自己拿主意(`assumeDefaults`)
24
+ 插件运行在宿主进程内,直接用公开服务(`sessionController`、`sessions`、`workspaceRegistry`、`userQuestions`),不需要读凭据文件,也不需要伪造 cookie。
60
25
 
61
- worker 卡在 `ask_user_question` 上时,需要有人在界面上点一下;但调用方是另一个 agent 时,那个人往往不在。
26
+ ---
62
27
 
63
- 所以桥在派发时会**自动追加一条常驻指令**:
28
+ ## 安装
64
29
 
30
+ ```bash
31
+ git clone https://github.com/Dogtiti/dsh-plugin-subagent-bridge
65
32
  ```
66
- (如果缺少必要信息或需要在多个方案里选择,直接采用最合理的默认做法继续做完,
67
- 不要把选择权抛回给用户、不要调用 ask_user_question 等回答;
68
- 在结果里用一行说明你假设了什么。)
69
- ```
70
-
71
- - 默认开启,配置项 `assumeDefaults: false` 可关掉;
72
- - `dsh_task`、`dsh_spawn`、`dsh_followup` 三条路径都会加;
73
- - prompt 里本来就提到 `ask_user_question` 时不追加,避免和调用方的明确意图打架。
74
33
 
75
- 效果:worker 不会停下来等人,而是选一个合理默认做法做完,并在结果里交代假设,调用方据此复核。
76
-
77
- ## 回答 worker 的问题(`dsh_answer`)
78
-
79
- worker 需要用户拍板时会调用 `ask_user_question`,然后**卡在那里等**。如果人不在,任务就停住了。
34
+ Plugins → 添加 → 选该目录。
80
35
 
81
- 桥把这种情况暴露出来:
36
+ 启动后在 `127.0.0.1:47821` 监听,并把端点写到 `~/.dsh/bridge/endpoint.json`(目录 0700、文件 0600):
82
37
 
83
- ```jsonc
84
- // dsh_wait 的返回
38
+ ```json
85
39
  {
86
- "status": { "w1": "running" },
87
- "timedOut": true,
88
- "pendingQuestion": {
89
- "callId": "call_abc",
90
- "questions": [
91
- {
92
- "id": "mode",
93
- "question": "用哪种模式?",
94
- "header": "Choose Mode",
95
- "options": [{ "label": "快速", "description": "只跑单测" }, { "label": "完整" }]
96
- }
97
- ]
98
- },
99
- "next": "blocked on a question: answer it with dsh_answer, or call dsh_wait again"
40
+ "url": "http://127.0.0.1:47821/mcp/<token>",
41
+ "port": 47821,
42
+ "token": "<token>",
43
+ "pid": 12345,
44
+ "startedAt": "2026-10-22T12:30:00.000Z"
100
45
  }
101
46
  ```
102
47
 
103
- 调用方(Codex / Claude)看到 `pendingQuestion` 就可以代用户回答:
48
+ ## 接到 Codex
104
49
 
105
- ```jsonc
106
- dsh_answer({
107
- "worker": "w1",
108
- "answers": [{ "id": "mode", "selected": ["快速"] }] // custom 可用于自由文本
109
- })
50
+ ```bash
51
+ codex mcp add dsh-bridge --url "$(node -p "require(process.env.HOME+'/.dsh/bridge/endpoint.json').url")"
52
+ codex mcp list # 验证
53
+ codex mcp remove dsh-bridge # 移除
110
54
  ```
111
55
 
112
- 细节:
113
-
114
- - 一个问题的 id 必须**恰好出现一次**,否则底层会报 `BAD_ANSWER`;
115
- - `selected` 是选项 label 数组,多选时给多个;跳过一个问题就给空数组;
116
- - 回答是插进正在等待的那个回合的(底层用 `steer`),worker 立刻继续;
117
- - 只有真卡在 `ask_user_question` 上才算「等待中」——工具一旦返回结果就不再报。
118
-
119
- ## 排队还是插话(`mode`)
56
+ 桌面 App 与 CLI 共用 `~/.codex/config.toml`,两端都会看到这条。
120
57
 
121
- DSH 的「繁忙时的发送行为」只有两种,桥把它们原样交给调用方:
58
+ > token 放在 URL 路径里(能力 URL),所以 Codex 侧不需要配环境变量——桌面 App 从 Finder 启动时拿不到自定义环境变量。也支持 `Authorization: Bearer <token>`,需要时可用 `--bearer-token-env-var`。
122
59
 
123
- | `mode` | DSH 里的名字 | 行为 |
124
- | --- | --- | --- |
125
- | `queue`(默认) | 排队发送 | 等当前回合跑完,再作为一个新回合处理 |
126
- | `steer` | 插话发送 | 并入当前回合,模型在下一步就能看到 |
127
-
128
- `dsh_task`、`dsh_spawn`、`dsh_followup` 都支持:
60
+ Claude Code 同理:
129
61
 
130
- ```js
131
- dsh_followup({ worker: "w1", message: "接下来只改 src 下的文件", mode: "steer" })
132
- dsh_task({ sessionId: "session-…", prompt: "停一下,改成只跑单测", mode: "steer" })
62
+ ```bash
63
+ claude mcp add --transport http dsh-bridge "<上面那个 url>" --scope user
133
64
  ```
134
65
 
135
- 两点实测结论:
136
-
137
- 1. **`steer` 不打断正在执行的工具**。实测 worker 正在跑 `sleep 90`,`steer` 的消息投递进去了,但要等那次工具调用返回、模型走到下一步才被看到——整整 70 秒。真想立刻停,是 DSH 界面上另一个动作(取消),不是投递模式。
138
- 2. **`steer` 不新开回合**(turn 编号不变),所以等待条件必须是「当前回合结束」。早期实现等的是「下一个回合」,答案都回来了、调用还在等,最后 120 秒超时。
139
-
140
- `dsh_task` 对正在运行的会话默认会拒绝,错误信息里会提示改用 `steer`。
66
+ ---
141
67
 
142
- 第三个细节是返回值:插话的回复和原任务在**同一个回合**里,所以不能简单取"这一轮最后一条 assistant 文本"——那可能是原任务后面的步骤。桥会在插话后定位到**自己那条 user/message**,只取它之后的 assistant 文本作为回答。
68
+ ## 正向:Codex → DSH
143
69
 
144
- ## 创建项目(workspace)
70
+ ### 工具
145
71
 
146
- DSH 的侧边栏分组是"项目(workspace)",和会话的工作目录是两回事:新建会话默认落在「未分组」,除非它所在的目录已经是一个项目。
72
+ | 工具 | 作用 |
73
+ | --- | --- |
74
+ | `dsh_task` | **同步**派活:开会话、投递、阻塞到这一轮结束,只返回**最终回答** |
75
+ | `dsh_spawn` | **异步**派活:立刻返回 `task_name` + `sessionId`,可并行开多个 |
76
+ | `dsh_followup` | 给已有 worker 再派一轮(复用上下文) |
77
+ | `dsh_wait` | 一次等多批 worker,任一到达终态即返回 |
78
+ | `dsh_close` | 关闭 worker,释放并发名额(不删除会话) |
79
+ | `dsh_answer` | 代用户回答 worker 提出的问题(见「问题上报」) |
80
+ | `dsh_list_workers` | 列出本桥开的 worker 及实时状态 |
81
+ | `dsh_dispatch` | 向指定会话投递一条用户消息并开始一轮;返回 `requestId` 与 `seqAtDispatch` |
82
+ | `dsh_read_result` | **长轮询**读该会话在 `seqAtDispatch` 之后的全部助手文本(看过程用;要结果用 `dsh_wait`) |
83
+ | `dsh_list_sessions` | 列出会话**元数据**(id、标题、工作目录、是否运行、最后活动时间) |
147
84
 
148
- `dsh_task` / `dsh_spawn` 的 `workspace` 参数把这一步也接管了:
85
+ ### 基本用法
149
86
 
150
87
  ```js
151
- // 目录不是项目 → 用它创建项目(标题取目录名),会话直接归到项目下
152
- dsh_task({ prompt: "…", cwd: "/repo/clawer", workspace: true })
88
+ // 同步:等它跑完,直接拿最终答案
89
+ dsh_task({ prompt: "审一下 src/bridge.ts 的错误处理", cwd: "/repo", timeoutSeconds: 300 })
153
90
 
154
- // 用指定标题创建
155
- dsh_spawn({ task_name: "w1", message: "…", cwd: "/repo/clawer", workspace: "Clawer" })
91
+ // 并行:先拿句柄,再批量等
92
+ dsh_spawn({ task_name: "audit_api", message: "…", cwd: "/repo" })
93
+ dsh_spawn({ task_name: "audit_ui", message: "…", cwd: "/repo" })
94
+ dsh_wait({ targets: ["audit_api", "audit_ui"], timeoutMs: 240000 })
156
95
 
157
- // 不传 → 只复用已有项目;目录没有项目就保持未分组(和以前一样)
158
- dsh_task({ prompt: "…", cwd: "/repo/clawer" })
96
+ // 续派:复用 worker 的上下文
97
+ dsh_followup({ worker: "audit_api", message: "把第 3 条展开,给出补丁" })
159
98
  ```
160
99
 
161
- 行为细节:
162
-
163
- - 目录不存在时先递归创建,再做项目登记(登记要求目录已存在);
164
- - 目录已经是项目 → **直接复用,不改标题**(底层 `create` 对同一路径返回已有记录);
165
- - 返回结果里带上 `workspace: { id, title, path }`,方便确认落在哪个项目;
166
- - 项目操作失败不影响派发本身(分组是外观,不因此让任务失败)。
167
-
168
- ## 排队还是立刻打断(`mode`)
100
+ 边界:
169
101
 
170
- DSH 的 prompt 有两种投递模式,桥把它们交给调用方选:
102
+ - `dsh_list_sessions` **只返回元数据**,不含任何消息内容;
103
+ - `dsh_read_result` 只对**本桥派发过**的会话生效,从未派发过的会话一律拒绝。
171
104
 
172
- | `mode` | 行为 |
173
- | --- | --- |
174
- | `queue`(默认) | 等当前回合跑完再处理这条消息 |
175
- | `steer` | 把消息**插进当前回合**(当前那一步仍会跑完) |
176
- | `interrupt` | **先取消当前回合**,再发送,真正立刻停下 |
105
+ ### 指定模型
177
106
 
178
- `dsh_task`、`dsh_spawn`、`dsh_followup` 都支持:
107
+ `dsh_task`、`dsh_spawn`、`dsh_followup` 都可以带 `model`:
179
108
 
180
109
  ```js
181
- dsh_followup({ worker: "w1", message: "别改了,先把现在的结果汇报一下", mode: "steer" })
182
- dsh_task({ sessionId: "session-…", prompt: "停一下,改成只跑单测", mode: "steer" })
110
+ dsh_task({ prompt: "…", model: "zai-coding-cn/glm-5.3", reasoningEffort: "max" })
111
+ dsh_spawn({ task_name: "review", message: "…", model: "opencode-go/deepseek-v4.1-flash" })
183
112
  ```
184
113
 
185
- 和 `dsh_dispatch` 的关系:`dsh_dispatch` 一直有 `mode`,但它只投递、不等待;`dsh_task` 会阻塞到回合结束,所以以前对正在运行的会话直接拒绝。现在传 `mode: "steer"` 就允许插进运行中的会话——**它会先打断当前回合**,然后等这一轮结束才返回。
186
-
187
- 两个实测出来的细节:
188
-
189
- 1. **`steer` 不会打断正在执行的工具**。实测:worker 正在跑 `sleep 90`,`steer` 进去后消息被**并入当前回合**,但要等那一步工具调用结束才会被模型看到——整整等了 70 秒。原因是 `steer` 只是投递,`cancel` 才是中止。
190
- 2. **`steer` 不新开回合**,所以等待条件不能是"下一个回合结束"。早期实现那样写,答案已经回来了、调用还在等,最后 120 秒超时。现在 steer 等的是「当前回合结束」。
114
+ - 写法是 `provider/model`;只写模型名时沿用当前默认 provider;
115
+ - `reasoningEffort` 可选,取值随模型;
116
+ - 不传就完全不动会话;
117
+ - 实测:请求确实切到指定模型,且**不会改写 profile 的默认模型**。
191
118
 
192
- 所以真要"按下就停",用 `interrupt`:它先 `cancel` 掉当前回合(最多等 15 秒确认),再以普通排队模式发消息,开启新回合。
119
+ 实现上是在建好会话、发第一个 prompt **之前**调一次 `selectModel`,所以 worker 第一轮就在新模型上跑。
193
120
 
194
- 返回里带 `mode` 字段,方便确认这次是排队还是打断。
121
+ ### 项目归属(`workspace`)
195
122
 
196
- ## 指定模型
197
-
198
- `dsh_task`、`dsh_spawn`、`dsh_followup` 都可以带 `model`,让 worker 跑在指定模型上:
123
+ DSH 的侧边栏分组(项目 / workspace)和工作目录是两回事:新建会话默认落在「未分组」,除非所在目录已经是项目。
199
124
 
200
125
  ```js
201
- dsh_task({ prompt: "…", model: "zai-coding-cn/glm-5.3", reasoningEffort: "max" })
202
- dsh_spawn({ task_name: "review", message: "…", model: "opencode-go/deepseek-v4.1-flash" })
203
- dsh_followup({ worker: "review", message: "…", model: "deepseek-account/deepseek-flash" })
126
+ dsh_task({ prompt: "…", cwd: "/repo/clawer", workspace: true }) // 用目录名建项目
127
+ dsh_spawn({ task_name: "w1", message: "…", cwd: "/repo/clawer", workspace: "Clawer" })
128
+ dsh_task({ prompt: "…", cwd: "/repo/clawer" }) // 只复用,不创建
204
129
  ```
205
130
 
206
- - 写法是 `provider/model`;**只写模型名**时沿用当前默认 provider(`model: "glm-5.3"`);
207
- - `reasoningEffort` 可选,取值随模型(`low` / `medium` / `high` / `max` 等);
208
- - 不传 `model` 就完全不动会话,行为和以前一致;
209
- - 实测(Host 侧 `sessionController.selectModel`):请求头里的 provider/model 确实变成指定的那组,且**不会改写 profile 的默认模型**。
210
-
211
- 实现上就是在建好会话、发第一个 prompt **之前**调一次 `selectModel`,所以 worker 的第一轮就已经在新模型上跑。
212
-
213
- ## 工具
214
-
215
- | 工具 | 作用 |
216
- | --- | --- |
217
- | `dsh_task` | **同步**派活:开会话、投递、阻塞到这一轮结束,只返回**最终回答**(最后一条 assistant 消息) |
218
- | `dsh_spawn` | **异步**派活:立刻返回 `task_name` + `sessionId`,可同时开多个 worker 并行跑 |
219
- | `dsh_followup` | 给已有 worker 再派一轮(复用它的上下文) |
220
- | `dsh_close` | 关闭 worker,释放并发名额(不删除会话) |
221
- | `dsh_list_workers` | 列出本桥开的 worker 及其实时状态 |
222
- | `dsh_wait` | 一次等多批 worker:`targets` + `timeoutMs`,任一到达终态即返回 `{completed}` / `{errored}` / `"running"` / `"not_found"` |
223
- | `dsh_dispatch` | 向指定会话投递一条用户消息并开始一轮;返回 `requestId` 与 `seqAtDispatch` |
224
- | `dsh_read_result` | **长轮询**读进度:返回该会话在 `seqAtDispatch` 之后的**全部**助手文本(用于看过程;要结果用 `dsh_wait`) |
225
- | `dsh_list_sessions` | 列出会话**元数据**(id、标题、工作目录、是否运行、最后活动时间) |
131
+ - 目录不存在时先递归创建,再做项目登记(登记要求目录已存在);
132
+ - 目录已经是项目 → **复用,不改标题**;
133
+ - 返回里带 `workspace: { id, title, path }`;
134
+ - 项目操作失败不影响派发本身。
226
135
 
227
- ## 反向:DSH → Codex
136
+ ### 排队还是插话(`mode`)
228
137
 
229
- 插件同时给本部署的 Agent 注册了一个工具:
138
+ DSH 的「繁忙时的发送行为」只有两种,桥原样交给调用方:
230
139
 
231
- | 工具 | 参数 | 返回 |
140
+ | `mode` | DSH 里的名字 | 行为 |
232
141
  | --- | --- | --- |
233
- | `codex_dispatch` | `prompt`(必填)、`cwd`、`resume`(线程 id)、`sandbox` | `{ ok, text, threadId, exitCode, durationMs, error? }` |
142
+ | `queue`(默认) | 排队发送 | 等当前回合跑完,作为新回合处理 |
143
+ | `steer` | 插话发送 | 并入当前回合,模型下一步就能看到 |
234
144
 
235
- 它执行 `codex exec [resume <id>] --json -o <临时文件>`,从事件流取 `thread.started`、从 `-o` 文件取最终消息;工具结果里会带 `[codex thread <id> · 12s]`,把那个 id 传给 `resume` 就能继续同一个 Codex 线程(实测能记住上一轮的回复)。
145
+ ```js
146
+ dsh_followup({ worker: "w1", message: "接下来只改 src 下的文件", mode: "steer" })
147
+ ```
236
148
 
237
- 注意:**`resume` 时 `sandbox` 与 `cwd` 不生效** —— `codex exec resume` 子命令本身不接受 `-s` / `-C`,线程沿用它原来的设置。
149
+ 两个实测结论:
238
150
 
239
- 边界(v0 硬约束):
151
+ 1. **`steer` 不打断正在执行的工具**——worker 若正卡在 `sleep 90` 这类调用里,消息要等它返回才被看到(实测 70 秒)。真想立刻停下是界面上的「取消」,不是投递模式。
152
+ 2. **`steer` 不新开回合**(turn 编号不变),所以桥等的是「当前回合结束」;等「下一回合」会一直等到超时。
240
153
 
241
- - `dsh_list_sessions` **只返回元数据**,不含任何消息内容。
242
- - `dsh_read_result` 只对**本桥派发过**的会话生效;从未派发过的会话一律拒绝。
154
+ `dsh_task` 对正在运行的会话默认拒绝,错误信息会提示改用 `steer`。插话的回复和原任务在同一回合里,桥会定位到**自己那条 user/message** 之后再取回答,避免混进原任务后续步骤的输出。
243
155
 
244
- ---
156
+ ### 无人值守(`assumeDefaults`)
245
157
 
246
- ## 安装
158
+ worker 卡在 `ask_user_question` 上时需要有人在界面上点一下,但调用方是另一个 agent 时那个人往往不在。因此桥在派发时**自动追加一条常驻指令**:
247
159
 
248
- ```bash
249
- # 本地目录安装
250
- git clone https://github.com/Dogtiti/dsh-plugin-subagent-bridge
160
+ ```
161
+ (如果缺少必要信息或需要在多个方案里选择,直接采用最合理的默认做法继续做完,
162
+ 不要把选择权抛回给用户、不要调用 ask_user_question 等回答;
163
+ 在结果里用一行说明你假设了什么。)
251
164
  ```
252
165
 
253
- Plugins → 添加 → 选该目录。
166
+ - 默认开启,`assumeDefaults: false` 可关;
167
+ - `dsh_task`、`dsh_spawn`、`dsh_followup` 三条路径都会加;
168
+ - prompt 里本来就提到 `ask_user_question` 时不追加。
254
169
 
255
- 启动后插件在 `127.0.0.1:47821` 监听,并写入端点文件(权限 0700/0600):
170
+ 效果:worker 不会停下来等人,而是选一个合理默认做法做完并交代假设,调用方据此复核。
256
171
 
257
- ```json
172
+ ### 问题上报(`pendingQuestion`)
173
+
174
+ worker 真的问了问题时,`dsh_wait` 会把问题原样带出来:
175
+
176
+ ```jsonc
258
177
  {
259
- "url": "http://127.0.0.1:47821/mcp/<token>",
260
- "port": 47821,
261
- "token": "<token>",
262
- "pid": 12345,
263
- "startedAt": "2026-10-22T12:30:00.000Z"
178
+ "status": { "w1": "running" },
179
+ "timedOut": true,
180
+ "pendingQuestion": {
181
+ "callId": "call_abc",
182
+ "questions": [{
183
+ "id": "mode",
184
+ "question": "用哪种模式?",
185
+ "header": "Choose Mode",
186
+ "options": [{ "label": "快速", "description": "只跑单测" }, { "label": "完整" }]
187
+ }]
188
+ },
189
+ "next": "blocked on a question: answer it with dsh_answer, or call dsh_wait again"
264
190
  }
265
191
  ```
266
192
 
267
- 路径:`~/.dsh/bridge/endpoint.json`
193
+ `dsh_answer({ worker, answers: [{ id, selected: [...] }] })` 用来代答。**已知限制**:DSH 的 `userQuestions.answer()` 只接受已经超时、进入 `continued` 状态的问题;还阻塞在工具调用里的问题是 Web UI 的 answerer 接管的,桥答不了(实测返回 `answered: false`)。所以实际使用中更该依赖上面的 `assumeDefaults`,而不是等 worker 提问。
268
194
 
269
- ## 接到 Codex
195
+ ---
270
196
 
271
- ```bash
272
- codex mcp add dsh-bridge --url "$(node -p "require(process.env.HOME+'/.dsh/bridge/endpoint.json').url")"
273
- ```
197
+ ## 反向:DSH → Codex / Claude Code
274
198
 
275
- 验证:
199
+ 两个工具都会给本部署的 Agent 注册,参数形状一致,差别只在底层 CLI。
276
200
 
277
- ```bash
278
- codex mcp list
279
- codex mcp get dsh-bridge
280
- ```
201
+ ### `codex_dispatch`
202
+
203
+ | 参数 | 说明 |
204
+ | --- | --- |
205
+ | `prompt` | 必填,任务正文 |
206
+ | `cwd` | 工作目录,默认当前会话目录 |
207
+ | `resume` | Codex 线程 id,续接同一线程 |
208
+ | `sandbox` | `read-only` / `workspace-write` / `danger-full-access` |
281
209
 
282
- Codex 的桌面 App 与 CLI 共用 `~/.codex/config.toml`,所以两端都会看到这条。
210
+ 返回 `{ ok, text, threadId, exitCode, durationMs }`。执行 `codex exec [resume <id>] --json -o <临时文件>`,从事件流取 `thread.started`、从 `-o` 取最终消息。**`resume` 时 `sandbox` 与 `cwd` 不生效**——`codex exec resume` 不接受 `-s` / `-C`。
283
211
 
284
- > token 存在 URL 路径里(能力 URL),因此不需要在 Codex 侧配置环境变量 —— 桌面 App 从 Finder 启动时拿不到自定义环境变量。若你更想用 bearer,也可以 `--bearer-token-env-var` 指向环境变量,插件同时支持 `Authorization: Bearer <token>`。
212
+ ### `claude_dispatch`
285
213
 
286
- 移除:
214
+ | 参数 | 说明 |
215
+ | --- | --- |
216
+ | `prompt` | 必填,任务正文 |
217
+ | `cwd` | 工作目录 |
218
+ | `resume` | Claude Code 的 session id |
219
+ | `model` | 模型别名或 id;不传则用配置,再退回 CLI 自己的设置 |
287
220
 
288
- ```bash
289
- codex mcp remove dsh-bridge
290
- ```
221
+ 返回 `{ ok, text, sessionId, durationMs }`。执行 `claude -p --output-format json`,只读 stdout 的单个 JSON 对象;**stderr 的警告不算失败**(模型名带 `[1M]` 后缀时 CLI 会警告,但结果正常)。
291
222
 
292
223
  ---
293
224
 
@@ -304,81 +235,76 @@ codex mcp remove dsh-bridge
304
235
  | 键 | 默认 | 说明 |
305
236
  | --- | --- | --- |
306
237
  | `enabled` | `true` | 总开关;关闭后不监听、不写端点文件 |
307
- | `port` | `47821` | 回环监听端口;固定端口是为了让 Codex 侧那条 URL 在重启后依然有效 |
308
- | `endpointFile` | `~/.dsh/bridge/endpoint.json` | 端点与令牌的落盘位置 |
309
- | `maxResultChars` | `20000` | 单次 `dsh_read_result` 返回的最大字符数(超出保留尾部) |
310
- | `taskTimeoutSeconds` | `300` | `dsh_task` 等待一轮结束的默认上限(客户端可用 `timeoutSeconds` 覆盖,最大 600) |
311
- | `workersFile` | `~/.dsh/bridge/workers.json` | worker 注册表与重基线的落盘位置(0600) |
312
- | `maxHops` | `1` | 跨工具委派层数上限(1 = 只允许一层,和 Codex / DSH 的默认 `maxDepth: 1` 一致) |
313
- | `dispatchPerMinute` | `10` | 滚动窗口内允许的派发次数(spawn / task / dispatch / followup / codex_dispatch 合计) |
238
+ | `port` | `47821` | 回环端口;固定端口让客户端那条 URL 重启后依然有效 |
239
+ | `endpointFile` | `~/.dsh/bridge/endpoint.json` | 端点与令牌落盘位置 |
240
+ | `maxResultChars` | `20000` | 单次结果返回的最大字符数(超出保留尾部) |
241
+ | `taskTimeoutSeconds` | `300` | `dsh_task` 等待上限(客户端可覆盖,最大 240) |
242
+ | `assumeDefaults` | `true` | 派发时追加「自行决策」的常驻指令 |
243
+ | `workersFile` | `~/.dsh/bridge/workers.json` | worker 注册表(0600) |
244
+ | `maxHops` | `1` | 跨工具委派层数上限 |
245
+ | `dispatchPerMinute` | `10` | 滚动窗口内的派发次数 |
314
246
  | `dispatchWindowMs` | `60000` | 上述窗口长度 |
315
- | `maxWorkersPerRoot` | `4` | **每个工作目录**的 worker 上限(`cwd` 自动分桶,调用方不需要传 owner) |
316
- | `maxWorkers` | `12` | 全局兜底上限,防止多个项目一起把机器压满 |
317
- | `workerTtlMs` | `86400000` | 记录超过这个时间没更新就不再占名额(过期自动释放,无需手动清理) |
318
- | `waitTimeoutMs` | `240000` | `dsh_wait` 的默认超时(1s ~ 240s,客户端可用 `timeoutMs` 覆盖) |
319
- | `readWaitSeconds` | `25` | `dsh_read_result` 的默认长轮询时长(上限 55,客户端可用 `waitSeconds` 覆盖) |
320
- | `codexBin` | `codex` | Codex 可执行文件;含 `/` 时按路径严格解析,找不到就报错而不回退 |
321
- | `codexSandbox` | `workspace-write` | `codex_dispatch` 的默认 sandbox(`resume` 时无效) |
322
- | `codexTimeoutMs` | `300000` | 单次 `codex_dispatch` 超时,超时先 SIGTERM 再 SIGKILL |
323
-
324
- 端口被占用时插件不会静默换端口:端到端文件里会写入 `error` 字段,并在宿主日志打印 `subagent-bridge: could not start on port …`。
247
+ | `maxWorkersPerRoot` | `4` | **每个工作目录**的 worker 上限 |
248
+ | `maxWorkers` | `12` | 全局上限 |
249
+ | `workerTtlMs` | `86400000` | 记录过期后不再占名额 |
250
+ | `waitTimeoutMs` | `240000` | `dsh_wait` 默认超时 |
251
+ | `readWaitSeconds` | `25` | `dsh_read_result` 默认长轮询时长 |
252
+ | `codexBin` | `codex` | Codex 可执行文件;含 `/` 时严格按路径解析 |
253
+ | `codexSandbox` | `workspace-write` | `codex_dispatch` 默认 sandbox |
254
+ | `codexTimeoutMs` | `300000` | 单次 `codex_dispatch` 超时 |
255
+ | `claudeBin` | `claude` | Claude Code 可执行文件 |
256
+ | `claudeModel` | 空 | 默认让 Claude CLI 用自己的设置 |
257
+ | `claudeTimeoutMs` | `300000` | 单次 `claude_dispatch` 超时 |
258
+
259
+ 端口被占用时不会静默换端口:端点文件里写入 `error` 字段,宿主日志打印 `subagent-bridge: could not start on port …`。
325
260
 
326
261
  ---
327
262
 
328
- ## 防环护栏
263
+ ## 护栏与限制
329
264
 
330
- 两个 agent 都接了同一个桥时,存在真实的环路:DSH worker → `codex_dispatch` → 那次 Codex 运行也能看到 `dsh-bridge` → 又 `dsh_spawn` → …。两边**各自进程内**的深度规则都管不到这条链(Codex 的 `agent_max_depth` 只数自己的 `spawn_agent`,DSH 的 `maxDepth` 只看 `delegationDepth`,而桥建的 worker 是顶层会话,深度恒为 0)。
265
+ ### 防环
331
266
 
332
- 所以桥自带两道:
267
+ 两个 agent 都接了同一个桥时存在真实环路:DSH worker → `codex_dispatch` → 那次 Codex 也能看到 `dsh-bridge` → 又 `dsh_spawn` → …。双方进程内的深度规则都管不到这条链(桥建的 worker 是顶层会话,深度恒为 0)。
333
268
 
334
- **① 深度(`maxHops`,默认 1)**
269
+ **① 深度(`maxHops`,默认 1)**:每次派发都计算 `max(在飞链路深度, 调用方深度) + 1`,超限直接报错:
335
270
 
336
- - 每次都计算这次派发会产生的深度:`max(当前在飞的链路深度, 调用方深度) + 1`;
337
- - 调用方是桥建的 worker 时按其深度 1 起算;
338
- - `codex_dispatch` 运行期间会把链路标记为"在飞",嵌套回来的调用继承该深度;
339
- - 超限直接报错,措辞可执行:
340
- `codex_dispatch would create delegation depth 2, but maxHops is 1. Carry the work out directly instead of delegating again.`
341
-
342
- 默认 1 的含义:**Codex 可以派给 DSH,但 DSH 不能再用桥派回去** —— 与两个产品各自的 `maxDepth: 1` 语义对齐。
343
-
344
- **② 速率(`dispatchPerMinute`,默认 10 / 60s)**
271
+ ```
272
+ codex_dispatch would create delegation depth 2, but maxHops is 1.
273
+ Carry the work out directly instead of delegating again.
274
+ ```
345
275
 
346
- 拦截广度型失控(一次开很多 worker)和客户端紧循环重试,与提示词无关,是纯兜底:
276
+ **② 速率(`dispatchPerMinute`,默认 10)**:拦截广度型失控与紧循环重试:
347
277
 
348
278
  ```
349
279
  rate limit: 10 dispatches per 60s reached; retry in 23s
350
280
  ```
351
281
 
352
- ## 名额怎么算
353
-
354
- worker 名额**按工作目录分桶**,调用方不用传任何 owner:
282
+ ### 名额
355
283
 
356
- - 每个 `cwd` 默认 **4 个**(`maxWorkersPerRoot`);
357
- - 全局默认 **12 个**(`maxWorkers`),跨项目兜底;
358
- - **完成但没 `dsh_close` 的仍占名额**(与 Codex 一致);
359
- - 记录 **24 小时**没更新就自动不再占名额(`workerTtlMs`),不需要手动清理;
284
+ - 每个 `cwd` 默认 **4 个**(`maxWorkersPerRoot`),全局 **12 个**(`maxWorkers`);
285
+ - **完成但没 `dsh_close` 的仍占名额**;
286
+ - 记录 **24 小时**没更新自动释放;
360
287
  - 阻塞式 `dsh_task` 不计入名额,只登记。
361
288
 
362
- 为什么不是"每个会话 4 个":MCP 端点是共享的,请求里没有会话或线程身份(Codex 的 `CODEX_THREAD_ID` 只存在于 shell 环境)。`cwd` 是唯一由调用方稳定提供、且语义上对应"一个项目"的维度。
289
+ 按 `cwd` 分桶而不是按会话,是因为 MCP 端点共享、请求里没有会话身份;`cwd` 是唯一由调用方稳定提供、且语义上对应「一个项目」的维度。
363
290
 
364
- ## 跨重启
291
+ ### 跨重启
365
292
 
366
- worker 注册表(`task_name` / `sessionId` / 终态结果)和每轮的时间基线都落在 `~/.dsh/bridge/workers.json`:
293
+ worker 注册表和每轮的时间基线落在 `~/.dsh/bridge/workers.json`:
367
294
 
368
- - 派发、完成、关闭都会更新它;
369
- - **重启后 `dsh_list_workers` 仍能看到之前的 worker**;
370
- - 已经结束的 worker,`dsh_wait` 直接返回缓存里的最终回答(不必重新打开会话);
371
- - 重启时还在跑的 worker,会话未打开时状态是 `offline`(结果未知,去 DSH 侧边栏打开那个会话即可查看);
372
- - `dsh_followup` 会给 worker 开新一轮,同时**清掉**缓存的旧回答;
373
- - 文件最多保留 200 条,超出时先淘汰最旧的 **已关闭** worker。
295
+ - 重启后 `dsh_list_workers` 仍能看到之前的 worker;
296
+ - 已结束的 worker,`dsh_wait` 直接返回缓存里的最终回答;
297
+ - 重启时还在跑的,会话未打开时状态为 `offline`;
298
+ - `dsh_followup` 会开新一轮并清掉旧回答;
299
+ - 最多保留 200 条,超出先淘汰最旧的已关闭 worker。
374
300
 
375
- ## 安全模型
301
+ ### 安全模型
376
302
 
377
- - 只监听 `127.0.0.1`,不对外暴露。
378
- - 请求必须携带令牌:路径 `/mcp/<token>`,或 `Authorization: Bearer <token>`;比较使用定长时间比较。
379
- - 令牌首次启动时生成并**持久化**(重启后复用),因此能力 URL 稳定。
380
- - 能力等价于「向指定会话投递消息」:本机任意进程只要能读到端点文件就能派活。文件权限 0600、目录 0700;不要把它复制到共享位置。
381
- - DSH 侧的权限策略不受影响:派发进去的消息仍按会话自身的 sandbox / 审批策略执行。
303
+ - 只监听 `127.0.0.1`;
304
+ - 请求必须带令牌:路径 `/mcp/<token>` 或 `Authorization: Bearer <token>`,定长时间比较;
305
+ - 令牌首次启动生成并持久化,能力 URL 稳定;
306
+ - 能力等价于「向指定会话投递消息」:能读到端点文件的进程就能派活,文件 0600、目录 0700;
307
+ - DSH 侧的权限策略不受影响,派进去的消息仍按会话自身的 sandbox / 审批策略执行。
382
308
 
383
309
  ---
384
310
 
@@ -391,22 +317,22 @@ Codex / 任意 MCP 客户端
391
317
  dsh-plugin-subagent-bridge(宿主进程内)
392
318
  · node:http 监听 127.0.0.1:47821
393
319
  · initialize / tools/list / tools/call / ping
394
- · sessionController.prompt({sessionId, requestId, mode, content})
320
+ · sessionController.prompt({ sessionId, requestId, mode, content })
395
321
  · sessions.get(id).snapshotEvents() 读回助手文本
396
322
  ```
397
323
 
398
- 协议实现为 **streamable HTTP 的无状态子集**:每个 POST 返回一段 `application/json`,不做服务端推送,因此取结果用**长轮询**——`dsh_read_result` 在服务端等,客户端一次调用即可拿到答案;客户端断开时服务端立即停止等待。
324
+ 协议是 **streamable HTTP 的无状态子集**:每个 POST 返回一段 `application/json`,不做服务端推送,因此取结果用**长轮询**——`dsh_read_result` 在服务端等,客户端一次调用即可拿到答案;客户端断开时服务端立即停止等待。
399
325
 
400
326
  ---
401
327
 
402
328
  ## 配套 skill
403
329
 
404
- `skills/` 下有两份可直接安装的 skill,把"什么时候该派活、怎么派、怎么收尾"写成模型能读的规则:
330
+ `skills/` 下有两份可直接安装的 skill,把「什么时候该派活、怎么派、怎么收尾」写成模型能读的规则:
405
331
 
406
332
  | Skill | 装给谁 | 作用 |
407
333
  | --- | --- | --- |
408
334
  | `deepseek-worker` | Codex | 让 Codex 把任务当 subagent 派给 DSH(`dsh_task`),一次调用等结果 |
409
- | `delegate-to-external-agent` | DSH | 让 DSH 的 agent 在需要独立视角/复核时派给外部 agent:Codex(`codex_dispatch`)或 Claude Code(`claude_dispatch`),含两者的差别与续接方式 |
335
+ | `delegate-to-external-agent` | DSH | 让 DSH 的 agent 在需要独立视角/复核时派给 Codex 或 Claude Code,含两者差别与续接方式 |
410
336
 
411
337
  ```bash
412
338
  # Codex 侧
@@ -416,17 +342,15 @@ mkdir -p ~/.codex/skills && cp -R skills/deepseek-worker ~/.codex/skills/
416
342
  mkdir -p ~/.dsh/skills && cp -R skills/delegate-to-external-agent ~/.dsh/skills/
417
343
  ```
418
344
 
419
- 两份都是标准 `SKILL.md`:YAML frontmatter(`name` + `description`)+ 正文。**触发靠 description**——模型平时只看到名字和描述,判断相关了才读正文,所以描述里写满了中英触发语和"不要用"的边界。改完立刻生效,不用重启:Codex 下一次会话、DSH 下一轮就能看到。
345
+ 两份都是标准 `SKILL.md`:YAML frontmatter(`name` + `description`)+ 正文。**触发靠 description**——模型平时只看到名字和描述,判断相关了才读正文,所以描述里写满了中英触发语和「不要用」的边界。改完立刻生效,不用重启。
420
346
 
421
347
  ---
422
348
 
423
349
  ## 兼容性
424
350
 
425
- - DSH `^0.2.1-0`(`engines.dsh`)。
426
- - MCP 协议版本:`2025-06-18`、`2025-03-26`、`2024-11-05`(按客户端请求回显,未知版本回落到最新)。
427
- - 依赖宿主公开服务 `sessionController`(`list` / `prompt`)与 `sessions.get()`;两者不可用时工具会返回明确错误。
428
-
429
- ---
351
+ - DSH `^0.2.1-0`(`engines.dsh`);
352
+ - MCP 协议版本:`2025-06-18`、`2025-03-26`、`2024-11-05`(按客户端请求回显,未知版本回落到最新);
353
+ - 依赖宿主公开服务 `sessionController`、`sessions`、`workspaceRegistry`、`userQuestions`;不可用时工具返回明确错误。
430
354
 
431
355
  ## 开发
432
356
 
@@ -434,8 +358,9 @@ mkdir -p ~/.dsh/skills && cp -R skills/delegate-to-external-agent ~/.dsh/skills/
434
358
  node --test "test/*.test.mjs"
435
359
  ```
436
360
 
437
- 58 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 HTTP 往返(含 404/405/202 与 bearer 分支)、`codex exec` 事件解析、二进制解析、成功/失败/超时三种运行结果、resume 的参数分叉、工具结果的 lossless JSON 约束、**只回最终回答**(多步叙述被丢弃)、`dsh_wait` 的任一完成 / 超时 / 错误 / 未派发四种状态、`dsh_spawn` 立即返回与注册、`fork_turns=all` 走 fork、并发上限与 `dsh_close` 释放名额、`dsh_followup` 的重基线与最终回答切换、注册表落盘与重启后复原、冷会话下的缓存回答与 `offline` 状态、注册表 200 条上限的淘汰顺序、`maxHops` 拒绝回派而放行首层、在飞链路阻塞嵌套派发、速率突发的拒绝与窗口过期、followup 计入同一预算、握手版本号取自 `package.json`(不再硬编码)、新建的 worker 会按 `cwd` 自动归入对应工作区(不再落到「未分组」)、阻塞式 `dsh_task` 不占 spawn 名额(重启后也不占)、按 `cwd` 分桶的每项目预算与全局上限、过期记录到期释放名额、超时返回 `running: true` 与续等指引、阻塞时长被夹到 240 秒以内。
438
- `test/persistence.test.mjs` 单独覆盖持久化:文件权限 0600、缺失/损坏/畸形记录、spawn 返回时文件已落盘、完成即缓存、followup 清缓存、close 后重启仍为 closed、冷会话走缓存或 `offline`、并发写不串。
361
+ 83 项,覆盖握手与版本协商、工具清单与参数校验、派发调用形状、结果读取的时间基线、长轮询、真实 HTTP 往返(含 404/405/202 与 bearer 分支)、`codex exec` 事件解析与二进制解析、成功/失败/超时三种运行结果、resume 的参数分叉、只回最终回答、`dsh_wait` 的四种终态、并发上限与释放、followup 重基线、注册表落盘与重启复原、`maxHops` 与速率限制、按 `cwd` 分桶的预算、工作区归属、`workspace` 创建与复用、`queue` / `steer` 的投递与等待、插话回答的定位、`claude` 输出解析与 resume 参数、`assumeDefaults` 指令注入、`pendingQuestion` 与 `dsh_answer`。
362
+
363
+ `test/persistence.test.mjs` 单独覆盖持久化:文件权限、缺失/损坏记录、并发写、冷会话缓存与 `offline`。
439
364
 
440
365
  ---
441
366
 
@@ -443,12 +368,11 @@ node --test "test/*.test.mjs"
443
368
 
444
369
  | 插件 | 说明 |
445
370
  | --- | --- |
371
+ | **dsh-plugin-subagent-bridge**(本仓库) | 让 Codex 通过本机 MCP 把 DSH 当 subagent 用,反向也能派给 Codex / Claude Code |
446
372
  | [dsh-plugin-composer-predict](https://github.com/Dogtiti/dsh-plugin-composer-predict) | 预测下一条指令并显示为占位文字,`Tab` 采纳 |
447
373
  | [dsh-plugin-composer-history](https://github.com/Dogtiti/dsh-plugin-composer-history) | 输入框中按 `↑` / `↓` 回填历史消息 |
448
- | **dsh-plugin-subagent-bridge**(本仓库) | 让 Codex 通过本机 MCP 把 DSH 当 subagent 用(spawn / wait / followup),反向也能把任务派给 Codex |
449
- | [dsh-plugin-usage-panel](https://github.com/Dogtiti/dsh-plugin-usage-panel) | 在模型选择器旁显示供应商套餐额度余量(目前 GLM Coding Plan),点开是明细浮层 |
450
- | [dsh-plugin-provider-icons](https://github.com/Dogtiti/dsh-plugin-provider-icons) | 在模型名旁显示供应商品牌 logo(24 个厂商,支持前缀匹配) |
451
-
374
+ | [dsh-plugin-usage-panel](https://github.com/Dogtiti/dsh-plugin-usage-panel) | 在模型选择器旁显示供应商套餐额度余量 |
375
+ | [dsh-plugin-provider-icons](https://github.com/Dogtiti/dsh-plugin-provider-icons) | 在模型名旁显示供应商品牌 logo |
452
376
 
453
377
  ## 许可
454
378
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "Subagent bridge: lets Codex delegate a task to DeepSeek Harness the way it would spawn a subagent (one call, waits for the answer), plus a codex_dispatch tool for the reverse direction.",
6
6
  "keywords": [