dsh-plugin-subagent-bridge 0.6.3 → 0.7.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 (3) hide show
  1. package/README.md +17 -2
  2. package/index.js +40 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -111,7 +111,9 @@ codex mcp remove dsh-bridge
111
111
  | `maxHops` | `1` | 跨工具委派层数上限(1 = 只允许一层,和 Codex / DSH 的默认 `maxDepth: 1` 一致) |
112
112
  | `dispatchPerMinute` | `10` | 滚动窗口内允许的派发次数(spawn / task / dispatch / followup / codex_dispatch 合计) |
113
113
  | `dispatchWindowMs` | `60000` | 上述窗口长度 |
114
- | `maxWorkers` | `4` | **整个 App 共享**的 worker 上限;已完成但未 `dsh_close` 的也占名额(与 Codex 一致),不按会话隔离 |
114
+ | `maxWorkersPerRoot` | `4` | **每个工作目录**的 worker 上限(`cwd` 自动分桶,调用方不需要传 owner) |
115
+ | `maxWorkers` | `12` | 全局兜底上限,防止多个项目一起把机器压满 |
116
+ | `workerTtlMs` | `86400000` | 记录超过这个时间没更新就不再占名额(过期自动释放,无需手动清理) |
115
117
  | `waitTimeoutMs` | `30000` | `dsh_wait` 的默认超时(1s ~ 1h,客户端可用 `timeoutMs` 覆盖) |
116
118
  | `readWaitSeconds` | `25` | `dsh_read_result` 的默认长轮询时长(上限 55,客户端可用 `waitSeconds` 覆盖) |
117
119
  | `codexBin` | `codex` | Codex 可执行文件;含 `/` 时按路径严格解析,找不到就报错而不回退 |
@@ -146,6 +148,18 @@ codex mcp remove dsh-bridge
146
148
  rate limit: 10 dispatches per 60s reached; retry in 23s
147
149
  ```
148
150
 
151
+ ## 名额怎么算
152
+
153
+ worker 名额**按工作目录分桶**,调用方不用传任何 owner:
154
+
155
+ - 每个 `cwd` 默认 **4 个**(`maxWorkersPerRoot`);
156
+ - 全局默认 **12 个**(`maxWorkers`),跨项目兜底;
157
+ - **完成但没 `dsh_close` 的仍占名额**(与 Codex 一致);
158
+ - 记录 **24 小时**没更新就自动不再占名额(`workerTtlMs`),不需要手动清理;
159
+ - 阻塞式 `dsh_task` 不计入名额,只登记。
160
+
161
+ 为什么不是"每个会话 4 个":MCP 端点是共享的,请求里没有会话或线程身份(Codex 的 `CODEX_THREAD_ID` 只存在于 shell 环境)。`cwd` 是唯一由调用方稳定提供、且语义上对应"一个项目"的维度。
162
+
149
163
  ## 跨重启
150
164
 
151
165
  worker 注册表(`task_name` / `sessionId` / 终态结果)和每轮的时间基线都落在 `~/.dsh/bridge/workers.json`:
@@ -219,7 +233,7 @@ mkdir -p ~/.dsh/skills && cp -R skills/delegate-to-codex ~/.dsh/skills/
219
233
  node --test "test/*.test.mjs"
220
234
  ```
221
235
 
222
- 53 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 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 名额(重启后也不占)。
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` 分桶的每项目预算与全局上限、过期记录到期释放名额。
223
237
  `test/persistence.test.mjs` 单独覆盖持久化:文件权限 0600、缺失/损坏/畸形记录、spawn 返回时文件已落盘、完成即缓存、followup 清缓存、close 后重启仍为 closed、冷会话走缓存或 `offline`、并发写不串。
224
238
 
225
239
  ---
@@ -230,6 +244,7 @@ node --test "test/*.test.mjs"
230
244
  | --- | --- |
231
245
  | [dsh-plugin-composer-predict](https://github.com/Dogtiti/dsh-plugin-composer-predict) | 预测下一条指令并显示为占位文字,`Tab` 采纳 |
232
246
  | [dsh-plugin-composer-history](https://github.com/Dogtiti/dsh-plugin-composer-history) | 输入框中按 `↑` / `↓` 回填历史消息 |
247
+ | **dsh-plugin-subagent-bridge**(本仓库) | 让 Codex 通过本机 MCP 把 DSH 当 subagent 用(spawn / wait / followup),反向也能把任务派给 Codex |
233
248
 
234
249
  ## 许可
235
250
 
package/index.js CHANGED
@@ -66,7 +66,9 @@ const DEFAULTS = {
66
66
  codexTimeoutMs: 300000,
67
67
  taskTimeoutSeconds: 300,
68
68
  waitTimeoutMs: 30000,
69
- maxWorkers: 4,
69
+ maxWorkers: 12,
70
+ maxWorkersPerRoot: 4,
71
+ workerTtlMs: 24 * 60 * 60 * 1000,
70
72
  maxHops: 1,
71
73
  dispatchPerMinute: 10,
72
74
  dispatchWindowMs: 60000,
@@ -590,9 +592,10 @@ export function createApi(ctx, state, settings) {
590
592
  const service = controller()
591
593
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
592
594
  if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
595
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : forkTurns === 'all' ? `fork:${String(from)}` : '(default)'
593
596
  guardDepth(state, settings, 0, 'dsh_spawn')
594
597
  takeDispatchSlot(state, settings)
595
- assertCapacity(state, settings)
598
+ assertCapacity(state, settings, root)
596
599
 
597
600
  let sessionId
598
601
  if (forkTurns === 'all') {
@@ -617,7 +620,15 @@ export function createApi(ctx, state, settings) {
617
620
  )
618
621
  state.dispatched.add(sessionId)
619
622
  state.baseline.set(sessionId, before)
620
- state.workers.set(taskName, { taskName, sessionId, status: 'open', countsTowardLimit: true, createdAt: Date.now(), updatedAt: Date.now() })
623
+ state.workers.set(taskName, {
624
+ taskName,
625
+ sessionId,
626
+ status: 'open',
627
+ countsTowardLimit: true,
628
+ root,
629
+ createdAt: Date.now(),
630
+ updatedAt: Date.now(),
631
+ })
621
632
  await saveWorkers(settings, state)
622
633
  return { task_name: taskName, sessionId, accepted: accepted?.accepted === true, fork_turns: forkTurns }
623
634
  },
@@ -660,7 +671,7 @@ export function createApi(ctx, state, settings) {
660
671
  for (const record of state.workers.values()) {
661
672
  const status = record.status === 'closed' ? 'closed' : workerStatus(ctx, record.sessionId, state, settings)
662
673
  if (record.status !== 'closed') await rememberOutcome(settings, state, record.sessionId, status)
663
- rows.push({ task_name: record.taskName, sessionId: record.sessionId, status })
674
+ rows.push({ task_name: record.taskName, sessionId: record.sessionId, root: record.root ?? '(default)', status })
664
675
  }
665
676
  return rows
666
677
  },
@@ -703,6 +714,7 @@ export function createApi(ctx, state, settings) {
703
714
  sessionId: targetId,
704
715
  status: 'open',
705
716
  countsTowardLimit: false,
717
+ root: typeof cwd === 'string' && cwd !== '' ? cwd : '(default)',
706
718
  createdAt: Date.now(),
707
719
  updatedAt: Date.now(),
708
720
  })
@@ -990,6 +1002,7 @@ export async function loadWorkers(settings, state) {
990
1002
  status: entry.status === 'closed' ? 'closed' : 'open',
991
1003
  /* Blocking tasks resume as non-counting records, exactly as they were written. */
992
1004
  countsTowardLimit: entry.countsTowardLimit !== false,
1005
+ ...(typeof entry.root === 'string' ? { root: entry.root } : {}),
993
1006
  createdAt: Number(entry.createdAt ?? Date.now()),
994
1007
  updatedAt: Number(entry.updatedAt ?? Date.now()),
995
1008
  ...(entry.outcome === undefined ? {} : { outcome: entry.outcome }),
@@ -1073,16 +1086,26 @@ function resolveWorker(state, reference) {
1073
1086
  * @param state - worker bookkeeping.
1074
1087
  * @param settings - resolved settings.
1075
1088
  */
1076
- function assertCapacity(state, settings) {
1077
- let open = 0
1089
+ function assertCapacity(state, settings, root) {
1090
+ const now = Date.now()
1091
+ let total = 0
1092
+ let inRoot = 0
1078
1093
  for (const record of state.workers.values()) {
1079
1094
  if (record.status === 'closed') continue
1080
1095
  if (record.countsTowardLimit === false) continue
1081
- open += 1
1096
+ /* A record nobody has touched for a whole TTL stops holding a slot. */
1097
+ if (now - Number(record.updatedAt ?? record.createdAt ?? 0) > settings.workerTtlMs) continue
1098
+ total += 1
1099
+ if (record.root === root) inRoot += 1
1100
+ }
1101
+ if (inRoot >= settings.maxWorkersPerRoot) {
1102
+ throw new Error(
1103
+ `worker limit reached for ${root} (${settings.maxWorkersPerRoot}); close one with dsh_close before spawning another`,
1104
+ )
1082
1105
  }
1083
- if (open >= settings.maxWorkers) {
1106
+ if (total >= settings.maxWorkers) {
1084
1107
  throw new Error(
1085
- `worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
1108
+ `global worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
1086
1109
  )
1087
1110
  }
1088
1111
  }
@@ -1567,6 +1590,14 @@ function resolveSettings(config) {
1567
1590
  const ms = Number(value)
1568
1591
  if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
1569
1592
  }
1593
+ if (key === 'maxWorkersPerRoot') {
1594
+ const count = Number(value)
1595
+ if (Number.isInteger(count) && count > 0) merged.maxWorkersPerRoot = count
1596
+ }
1597
+ if (key === 'workerTtlMs') {
1598
+ const ms = Number(value)
1599
+ if (Number.isFinite(ms) && ms > 0) merged.workerTtlMs = ms
1600
+ }
1570
1601
  if (key === 'maxWorkers') {
1571
1602
  const count = Number(value)
1572
1603
  if (Number.isInteger(count) && count > 0) merged.maxWorkers = count
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.6.3",
3
+ "version": "0.7.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": [