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.
- package/README.md +17 -2
- package/index.js +40 -9
- 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
|
-
| `
|
|
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
|
-
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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": [
|