dsh-plugin-subagent-bridge 0.7.1 → 0.7.2

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 CHANGED
@@ -114,7 +114,7 @@ codex mcp remove dsh-bridge
114
114
  | `maxWorkersPerRoot` | `4` | **每个工作目录**的 worker 上限(`cwd` 自动分桶,调用方不需要传 owner) |
115
115
  | `maxWorkers` | `12` | 全局兜底上限,防止多个项目一起把机器压满 |
116
116
  | `workerTtlMs` | `86400000` | 记录超过这个时间没更新就不再占名额(过期自动释放,无需手动清理) |
117
- | `waitTimeoutMs` | `30000` | `dsh_wait` 的默认超时(1s ~ 1h,客户端可用 `timeoutMs` 覆盖) |
117
+ | `waitTimeoutMs` | `240000` | `dsh_wait` 的默认超时(1s ~ 240s,客户端可用 `timeoutMs` 覆盖) |
118
118
  | `readWaitSeconds` | `25` | `dsh_read_result` 的默认长轮询时长(上限 55,客户端可用 `waitSeconds` 覆盖) |
119
119
  | `codexBin` | `codex` | Codex 可执行文件;含 `/` 时按路径严格解析,找不到就报错而不回退 |
120
120
  | `codexSandbox` | `workspace-write` | `codex_dispatch` 的默认 sandbox(`resume` 时无效) |
@@ -233,7 +233,7 @@ mkdir -p ~/.dsh/skills && cp -R skills/delegate-to-codex ~/.dsh/skills/
233
233
  node --test "test/*.test.mjs"
234
234
  ```
235
235
 
236
- 55 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 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`(不再硬编码)、阻塞式 `dsh_task` 不占 spawn 名额(重启后也不占)、按 `cwd` 分桶的每项目预算与全局上限、过期记录到期释放名额。
236
+ 57 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 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`(不再硬编码)、阻塞式 `dsh_task` 不占 spawn 名额(重启后也不占)、按 `cwd` 分桶的每项目预算与全局上限、过期记录到期释放名额、超时返回 `running: true` 与续等指引、阻塞时长被夹到 240 秒以内。
237
237
  `test/persistence.test.mjs` 单独覆盖持久化:文件权限 0600、缺失/损坏/畸形记录、spawn 返回时文件已落盘、完成即缓存、followup 清缓存、close 后重启仍为 closed、冷会话走缓存或 `offline`、并发写不串。
238
238
 
239
239
  ---
package/index.js CHANGED
@@ -37,6 +37,9 @@ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
37
37
  /** Largest JSON-RPC body accepted on the bridge endpoint. */
38
38
  const MAX_BODY_BYTES = 1024 * 1024
39
39
 
40
+ /** Longest a single tool call may block: the MCP client gives up at 300s. */
41
+ const MAX_BLOCKING_SECONDS = 240
42
+
40
43
  /** Worker records kept on disk; the oldest closed ones drop off first. */
41
44
  const MAX_PERSISTED_WORKERS = 200
42
45
 
@@ -60,12 +63,12 @@ const DEFAULTS = {
60
63
  port: 47821,
61
64
  endpointFile: join(homedir(), '.dsh', 'bridge', 'endpoint.json'),
62
65
  maxResultChars: MAX_RESULT_CHARS,
63
- readWaitSeconds: 25,
66
+ readWaitSeconds: 45,
64
67
  codexBin: 'codex',
65
68
  codexSandbox: 'workspace-write',
66
69
  codexTimeoutMs: 300000,
67
- taskTimeoutSeconds: 300,
68
- waitTimeoutMs: 30000,
70
+ taskTimeoutSeconds: MAX_BLOCKING_SECONDS,
71
+ waitTimeoutMs: MAX_BLOCKING_SECONDS * 1000,
69
72
  maxWorkers: 12,
70
73
  maxWorkersPerRoot: 4,
71
74
  workerTtlMs: 24 * 60 * 60 * 1000,
@@ -80,7 +83,7 @@ const TOOLS = [
80
83
  {
81
84
  name: 'dsh_task',
82
85
  description:
83
- 'Delegate one self-contained task to DeepSeek Harness and wait for the finished answer, exactly like spawning a subagent: the bridge opens a fresh DSH session in `cwd`, delivers the task, blocks until that turn ends, and returns the answer. Use this for real work delegation; no session id is needed. The prompt is delivered verbatim as an ordinary user message in that session, so write it as a work order for a colleague: do not mention your own tools, skills, or role framing — the worker has no such skill and will only waste a turn looking for it.',
86
+ 'Delegate one self-contained task to DeepSeek Harness and wait for the finished answer, exactly like spawning a subagent: the bridge opens a fresh DSH session in `cwd`, delivers the task, blocks up to 240 seconds, and returns the answer. A result with status "timeout" and running:true means the worker is STILL WORKING: keep the turn open and call dsh_wait or dsh_task again until it is no longer running. Never pass a timeout above 240 seconds — the MCP client aborts tool calls after 300. The prompt is delivered verbatim as an ordinary user message in that session, so write it as a work order for a colleague: do not mention your own tools, skills, or role framing — the worker has no such skill and will only waste a turn looking for it.',
84
87
  inputSchema: {
85
88
  type: 'object',
86
89
  properties: {
@@ -100,7 +103,7 @@ const TOOLS = [
100
103
  {
101
104
  name: 'dsh_spawn',
102
105
  description:
103
- 'Start a worker and return immediately with its task_name, so several workers can run in parallel; collect answers with dsh_wait. Use fork_turns "all" with `from` to start the worker from a copy of an existing DSH session instead of an empty one.',
106
+ 'Start a worker and return immediately with its task_name, so several workers can run in parallel. This tool does NOT wait and the result is never pushed back, so you must collect it in this same turn with dsh_wait (each call blocks up to 240 seconds; repeat until every target is no longer "running") before you end the turn. Use fork_turns "all" with `from` to start the worker from a copy of an existing DSH session.',
104
107
  inputSchema: {
105
108
  type: 'object',
106
109
  properties: {
@@ -170,7 +173,7 @@ const TOOLS = [
170
173
  {
171
174
  name: 'dsh_wait',
172
175
  description:
173
- 'Wait until any of the given workers reaches a final state, then return per-worker status. A finished worker carries its final answer only. Prefer long waits over polling.',
176
+ 'Wait until any of the given workers reaches a final state, then return per-worker status. A finished worker carries its final answer only. Long waits beat polling: pass timeoutMs: 240000, and when the reply still lists "running" workers, call this again instead of ending the turn.',
174
177
  inputSchema: {
175
178
  type: 'object',
176
179
  properties: {
@@ -679,7 +682,10 @@ export function createApi(ctx, state, settings) {
679
682
  async task({ prompt, cwd, sessionId, agentPreset, timeoutSeconds }) {
680
683
  const service = controller()
681
684
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
682
- const seconds = Math.min(600, Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds))
685
+ const seconds = Math.min(
686
+ MAX_BLOCKING_SECONDS,
687
+ Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds),
688
+ )
683
689
  guardDepth(state, settings, 0, 'dsh_task')
684
690
  takeDispatchSlot(state, settings)
685
691
  const started = Date.now()
@@ -725,10 +731,18 @@ export function createApi(ctx, state, settings) {
725
731
  const collected = finalAnswer(ctx, targetId, before, settings)
726
732
  const turnReason = end?.reason?.kind ?? null
727
733
  const durationMs = Date.now() - started
734
+ const status = end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`
728
735
  return {
729
736
  sessionId: targetId,
730
737
  created,
731
- status: end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`,
738
+ status,
739
+ /* A timeout means "still running": say so, and say what to call next. */
740
+ ...(status === 'timeout'
741
+ ? {
742
+ running: true,
743
+ next: `the worker is still running; do not end the turn. Call dsh_wait({targets:["${targetId}"],timeoutMs:${String(MAX_BLOCKING_SECONDS * 1000)}}) and repeat until it is not "running".`,
744
+ }
745
+ : {}),
732
746
  text: collected.text,
733
747
  truncated: collected.truncated,
734
748
  turns: lastTurn(ctx, targetId),
@@ -738,7 +752,10 @@ export function createApi(ctx, state, settings) {
738
752
  },
739
753
 
740
754
  async wait({ targets, timeoutMs }) {
741
- const budget = Math.min(3600000, Math.max(1000, Number.isFinite(timeoutMs) ? timeoutMs : settings.waitTimeoutMs))
755
+ const budget = Math.min(
756
+ MAX_BLOCKING_SECONDS * 1000,
757
+ Math.max(1000, Number.isFinite(timeoutMs) ? timeoutMs : settings.waitTimeoutMs),
758
+ )
742
759
  const started = Date.now()
743
760
  for (;;) {
744
761
  const status = {}
@@ -755,7 +772,20 @@ export function createApi(ctx, state, settings) {
755
772
  }
756
773
  const waitedMs = Date.now() - started
757
774
  if (settled) return { status, timedOut: false, waitedMs }
758
- if (waitedMs >= budget) return { status, timedOut: true, waitedMs }
775
+ if (waitedMs >= budget) {
776
+ const still = targets.filter((target) => status[target] === 'running' || status[target] === 'pending')
777
+ return {
778
+ status,
779
+ timedOut: true,
780
+ waitedMs,
781
+ ...(still.length === 0
782
+ ? {}
783
+ : {
784
+ running: true,
785
+ next: `still running: ${still.join(', ')}; call dsh_wait again with the same targets until none of them are "running"`,
786
+ }),
787
+ }
788
+ }
759
789
  await sleep(Math.min(300, budget - waitedMs))
760
790
  }
761
791
  },
@@ -1604,11 +1634,11 @@ function resolveSettings(config) {
1604
1634
  }
1605
1635
  if (key === 'waitTimeoutMs') {
1606
1636
  const ms = Number(value)
1607
- if (Number.isFinite(ms) && ms > 0) merged.waitTimeoutMs = Math.min(3600000, Math.max(1000, ms))
1637
+ if (Number.isFinite(ms) && ms > 0) merged.waitTimeoutMs = Math.min(MAX_BLOCKING_SECONDS * 1000, Math.max(1000, ms))
1608
1638
  }
1609
1639
  if (key === 'taskTimeoutSeconds') {
1610
1640
  const seconds = Number(value)
1611
- if (Number.isFinite(seconds) && seconds > 0) merged.taskTimeoutSeconds = Math.min(600, seconds)
1641
+ if (Number.isFinite(seconds) && seconds > 0) merged.taskTimeoutSeconds = Math.min(MAX_BLOCKING_SECONDS, seconds)
1612
1642
  }
1613
1643
  if (key === 'codexTimeoutMs') {
1614
1644
  const ms = Number(value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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": [
@@ -5,7 +5,12 @@ description: 交给 DSH / 开个 worker 时使用;把自足任务当 subagent
5
5
 
6
6
  # 把任务交给 DSH(subagent 用法)
7
7
 
8
- DSH 是通过 `dsh-bridge` MCP 接上来的另一个编码代理。用法和"给自己开一个 subagent"一样:**一次调用,给它一个自足的任务,等它把这一轮干完并返回结论。**
8
+ DSH 是通过 `dsh-bridge` MCP 接上来的另一个编码代理。用法和"给自己开一个 subagent"一样:**给它一个自足的任务,等它把这一轮干完并返回结论。**
9
+
10
+ > **最重要的一条:任务没跑完,不许结束回合。**
11
+ > Codex 原生的 subagent 会把结果**推送**回来、自动唤醒你;这个桥是**拉取**式的 —— 你结束回合,结果就没人接收了,用户只能人工来叫你回去。所以:只要还有 `running` 的 worker,就必须继续调 `dsh_wait` / `dsh_task` / `dsh_read_result`,直到全部到终态。
12
+
13
+ > **单次调用最长 240 秒。** Codex 的 MCP 客户端在 **300 秒**时会把工具调用判为失败(`timed out awaiting tools/call after 300s`)。所以 `timeoutSeconds` / `timeoutMs` / `waitSeconds` 一律不要超过 240,超了也不会生效(会被夹到 240)。
9
14
 
10
15
  ## 先确认工具在
11
16
 
@@ -36,7 +41,22 @@ dsh_task({
36
41
  }
37
42
  ```
38
43
 
39
- `dsh_task` **阻塞到 DSH 那一轮结束**再返回,不需要轮询。默认最多等 300 秒,更重的任务传 `timeoutSeconds`(上限 600)。
44
+ `dsh_task` **阻塞到 DSH 那一轮结束**再返回,最多等 **240 秒**(默认值,也是上限)。超时它不会假装成功,而是明确告诉你还在跑:
45
+
46
+ ```jsonc
47
+ { "status": "timeout", "running": true, "text": "",
48
+ "next": "the worker is still running; do not end the turn. Call dsh_wait(...) and repeat until it is not running." }
49
+ ```
50
+
51
+ **看到 `running: true` 就继续等**,不要结束回合、也不要把空 `text` 当成"它没干成"。
52
+
53
+ 最省事的续等方式(`dsh_task` 本身会阻塞,再调一次同一个 sessionId 等价于继续等):
54
+
55
+ ```jsonc
56
+ dsh_task({ "sessionId": "session-…", "prompt": "继续", "timeoutSeconds": 240 })
57
+ ```
58
+
59
+ 更好的方式是不用 `dsh_task` 的续做语义,而是直接用 `dsh_wait` 等它(见下节)。
40
60
 
41
61
  ## 多件活:并行开 worker
42
62
 
@@ -64,7 +84,7 @@ dsh_close({ "worker": "audit_api" })
64
84
 
65
85
  - **并发上限 4 个 worker**(`maxWorkers`)。**已完成但没 `dsh_close` 的也占名额** —— 用完就关,别一直挂着。
66
86
  - `dsh_task` 是阻塞的、一次一件;它**不占**那 4 个名额,适合"我就等着这一件"。
67
- - `dsh_wait` 给**长一点的超时**(默认 30 秒偏短,重活用 2~5 分钟),不要用短超时反复轮询 —— Codex 的原话是 "prefer longer waits (minutes) to avoid busy polling"。
87
+ - `dsh_wait` 每次给满 **240000**(毫秒)。**它返回 `timedOut: true` / 仍有 `"running"` 时,不代表结束,只代表"还没好"** —— 继续调它,直到没有任何 target 是 `running`/`pending`。短超时反复轮询是错的(Codex 原话:prefer longer waits (minutes) to avoid busy polling)。
68
88
  - `dsh_wait` 只要**任意一个**到终态就返回,所以它是"收割完成项"的循环,而不是"等全部"。
69
89
 
70
90
  **不要并行的情况**
@@ -114,11 +134,21 @@ worker 收到的是**一条普通用户消息**,它看不到你这边有没有
114
134
 
115
135
  一旦 prompt 里出现 `deepseek-worker`,worker 会以为那是它自己的技能或工具,白费一轮去确认「本会话没有这个技能」。
116
136
 
137
+ ## 收工的判断标准
138
+
139
+ 下面三件事同时成立,才可以结束回合:
140
+
141
+ 1. 所有派出去的 worker 状态都是 `{completed}` / `{errored}` / `"closed"` / `"not_found"` / `"offline"`;
142
+ 2. 没有任何 worker 是 `"running"` 或 `"pending"`;
143
+ 3. 你已经对每个完成的 worker 做过必要的复核(跑测试、看 diff)。
144
+
145
+ 只要还有 `running`,就继续 `dsh_wait`。**宁可多等一轮,也不要留一个没人收的 worker。**
146
+
117
147
  ## 拿到结果之后
118
148
 
119
149
  - `completed` → 读 `text`,判断是否真的达成交付;需要复核就自己跑一遍验证命令。
120
150
  - `turn-…`(如 `turn-aborted`、`turn-error`)→ 那一轮没正常结束,把 `text` 里的信息转述给用户,必要时自己接手。
121
- - `timeout` → 工作**没有丢**,DSH 那边还在跑。用同一个 `sessionId` 再调一次问进度,或先用低阶工具看一眼。
151
+ - `timeout`(带 `running: true`)→ 工作**没有丢**,DSH 那边还在跑。**必须继续等**:`dsh_wait({targets:[…]})` 或再调一次 `dsh_task({sessionId, …})`,直到不是 running。
122
152
 
123
153
  ## 同一个 worker 继续干
124
154