dsh-plugin-subagent-bridge 0.5.0 → 0.6.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 +28 -1
- package/index.js +131 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -108,6 +108,9 @@ codex mcp remove dsh-bridge
|
|
|
108
108
|
| `maxResultChars` | `20000` | 单次 `dsh_read_result` 返回的最大字符数(超出保留尾部) |
|
|
109
109
|
| `taskTimeoutSeconds` | `300` | `dsh_task` 等待一轮结束的默认上限(客户端可用 `timeoutSeconds` 覆盖,最大 600) |
|
|
110
110
|
| `workersFile` | `~/.dsh/bridge/workers.json` | worker 注册表与重基线的落盘位置(0600) |
|
|
111
|
+
| `maxHops` | `1` | 跨工具委派层数上限(1 = 只允许一层,和 Codex / DSH 的默认 `maxDepth: 1` 一致) |
|
|
112
|
+
| `dispatchPerMinute` | `10` | 滚动窗口内允许的派发次数(spawn / task / dispatch / followup / codex_dispatch 合计) |
|
|
113
|
+
| `dispatchWindowMs` | `60000` | 上述窗口长度 |
|
|
111
114
|
| `maxWorkers` | `4` | 同时打开的 worker 上限;**已完成但未 `dsh_close` 的也占名额**(与 Codex 一致) |
|
|
112
115
|
| `waitTimeoutMs` | `30000` | `dsh_wait` 的默认超时(1s ~ 1h,客户端可用 `timeoutMs` 覆盖) |
|
|
113
116
|
| `readWaitSeconds` | `25` | `dsh_read_result` 的默认长轮询时长(上限 55,客户端可用 `waitSeconds` 覆盖) |
|
|
@@ -119,6 +122,30 @@ codex mcp remove dsh-bridge
|
|
|
119
122
|
|
|
120
123
|
---
|
|
121
124
|
|
|
125
|
+
## 防环护栏
|
|
126
|
+
|
|
127
|
+
两个 agent 都接了同一个桥时,存在真实的环路:DSH worker → `codex_dispatch` → 那次 Codex 运行也能看到 `dsh-bridge` → 又 `dsh_spawn` → …。两边**各自进程内**的深度规则都管不到这条链(Codex 的 `agent_max_depth` 只数自己的 `spawn_agent`,DSH 的 `maxDepth` 只看 `delegationDepth`,而桥建的 worker 是顶层会话,深度恒为 0)。
|
|
128
|
+
|
|
129
|
+
所以桥自带两道:
|
|
130
|
+
|
|
131
|
+
**① 深度(`maxHops`,默认 1)**
|
|
132
|
+
|
|
133
|
+
- 每次都计算这次派发会产生的深度:`max(当前在飞的链路深度, 调用方深度) + 1`;
|
|
134
|
+
- 调用方是桥建的 worker 时按其深度 1 起算;
|
|
135
|
+
- `codex_dispatch` 运行期间会把链路标记为"在飞",嵌套回来的调用继承该深度;
|
|
136
|
+
- 超限直接报错,措辞可执行:
|
|
137
|
+
`codex_dispatch would create delegation depth 2, but maxHops is 1. Carry the work out directly instead of delegating again.`
|
|
138
|
+
|
|
139
|
+
默认 1 的含义:**Codex 可以派给 DSH,但 DSH 不能再用桥派回去** —— 与两个产品各自的 `maxDepth: 1` 语义对齐。
|
|
140
|
+
|
|
141
|
+
**② 速率(`dispatchPerMinute`,默认 10 / 60s)**
|
|
142
|
+
|
|
143
|
+
拦截广度型失控(一次开很多 worker)和客户端紧循环重试,与提示词无关,是纯兜底:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
rate limit: 10 dispatches per 60s reached; retry in 23s
|
|
147
|
+
```
|
|
148
|
+
|
|
122
149
|
## 跨重启
|
|
123
150
|
|
|
124
151
|
worker 注册表(`task_name` / `sessionId` / 终态结果)和每轮的时间基线都落在 `~/.dsh/bridge/workers.json`:
|
|
@@ -192,7 +219,7 @@ mkdir -p ~/.dsh/skills && cp -R skills/delegate-to-codex ~/.dsh/skills/
|
|
|
192
219
|
node --test "test/*.test.mjs"
|
|
193
220
|
```
|
|
194
221
|
|
|
195
|
-
|
|
222
|
+
51 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 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`(不再硬编码)。
|
|
196
223
|
`test/persistence.test.mjs` 单独覆盖持久化:文件权限 0600、缺失/损坏/畸形记录、spawn 返回时文件已落盘、完成即缓存、followup 清缓存、close 后重启仍为 closed、冷会话走缓存或 `offline`、并发写不串。
|
|
197
224
|
|
|
198
225
|
---
|
package/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
|
-
import { existsSync } from 'node:fs'
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
17
|
import { createServer } from 'node:http'
|
|
18
18
|
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
19
19
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
@@ -22,6 +22,15 @@ import { dirname, join } from 'node:path'
|
|
|
22
22
|
|
|
23
23
|
export const name = 'subagent-bridge'
|
|
24
24
|
|
|
25
|
+
/** Version advertised in the MCP handshake, read from the manifest so it cannot drift. */
|
|
26
|
+
const PACKAGE_VERSION = (() => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
|
|
29
|
+
} catch {
|
|
30
|
+
return '0.0.0'
|
|
31
|
+
}
|
|
32
|
+
})()
|
|
33
|
+
|
|
25
34
|
/** MCP protocol revisions this server understands, newest first. */
|
|
26
35
|
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
|
|
27
36
|
|
|
@@ -58,6 +67,9 @@ const DEFAULTS = {
|
|
|
58
67
|
taskTimeoutSeconds: 300,
|
|
59
68
|
waitTimeoutMs: 30000,
|
|
60
69
|
maxWorkers: 4,
|
|
70
|
+
maxHops: 1,
|
|
71
|
+
dispatchPerMinute: 10,
|
|
72
|
+
dispatchWindowMs: 60000,
|
|
61
73
|
workersFile: join(homedir(), '.dsh', 'bridge', 'workers.json'),
|
|
62
74
|
}
|
|
63
75
|
|
|
@@ -430,7 +442,7 @@ export async function handleJsonRpc(message, api, options = {}) {
|
|
|
430
442
|
return ok(id, {
|
|
431
443
|
protocolVersion: version,
|
|
432
444
|
capabilities: { tools: { listChanged: false } },
|
|
433
|
-
serverInfo: { name: 'dsh-subagent-bridge', version:
|
|
445
|
+
serverInfo: { name: 'dsh-subagent-bridge', version: PACKAGE_VERSION },
|
|
434
446
|
})
|
|
435
447
|
}
|
|
436
448
|
case 'ping':
|
|
@@ -545,6 +557,8 @@ export function createApi(ctx, state, settings) {
|
|
|
545
557
|
state.dispatched = state.dispatched ?? new Set()
|
|
546
558
|
state.baseline = state.baseline ?? new Map()
|
|
547
559
|
state.workers = state.workers ?? new Map()
|
|
560
|
+
state.chain = state.chain ?? { depth: 0, active: 0, until: 0 }
|
|
561
|
+
state.recent = state.recent ?? []
|
|
548
562
|
const controller = () => ctx.get('sessionController')
|
|
549
563
|
const sessions = () => ctx.get('sessions')
|
|
550
564
|
|
|
@@ -576,6 +590,8 @@ export function createApi(ctx, state, settings) {
|
|
|
576
590
|
const service = controller()
|
|
577
591
|
if (service === undefined || service === null) throw new Error('session controller is unavailable')
|
|
578
592
|
if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
|
|
593
|
+
guardDepth(state, settings, 0, 'dsh_spawn')
|
|
594
|
+
takeDispatchSlot(state, settings)
|
|
579
595
|
assertCapacity(state, settings)
|
|
580
596
|
|
|
581
597
|
let sessionId
|
|
@@ -611,6 +627,7 @@ export function createApi(ctx, state, settings) {
|
|
|
611
627
|
if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
|
|
612
628
|
const service = controller()
|
|
613
629
|
if (service === undefined || service === null) throw new Error('session controller is unavailable')
|
|
630
|
+
takeDispatchSlot(state, settings)
|
|
614
631
|
const before = Number(sessions()?.get?.(target.sessionId)?.seq ?? 0)
|
|
615
632
|
const requestId = randomUUID()
|
|
616
633
|
const accepted = await service.prompt(
|
|
@@ -652,6 +669,8 @@ export function createApi(ctx, state, settings) {
|
|
|
652
669
|
const service = controller()
|
|
653
670
|
if (service === undefined || service === null) throw new Error('session controller is unavailable')
|
|
654
671
|
const seconds = Math.min(600, Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds))
|
|
672
|
+
guardDepth(state, settings, 0, 'dsh_task')
|
|
673
|
+
takeDispatchSlot(state, settings)
|
|
655
674
|
const started = Date.now()
|
|
656
675
|
|
|
657
676
|
let targetId = sessionId
|
|
@@ -723,6 +742,8 @@ export function createApi(ctx, state, settings) {
|
|
|
723
742
|
async dispatch({ sessionId, text, mode }) {
|
|
724
743
|
const service = controller()
|
|
725
744
|
if (service === undefined || service === null) throw new Error('session controller is unavailable')
|
|
745
|
+
guardDepth(state, settings, 0, 'dsh_dispatch')
|
|
746
|
+
takeDispatchSlot(state, settings)
|
|
726
747
|
const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
|
|
727
748
|
const requestId = randomUUID()
|
|
728
749
|
const result = await service.prompt(
|
|
@@ -757,17 +778,24 @@ export function createApi(ctx, state, settings) {
|
|
|
757
778
|
|
|
758
779
|
async dispatchCodex(args, exec) {
|
|
759
780
|
const prompt = requireString(args?.prompt, 'prompt')
|
|
781
|
+
const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'codex_dispatch')
|
|
782
|
+
takeDispatchSlot(state, settings)
|
|
760
783
|
const cwd = await resolveCwd(ctx, args?.cwd, exec)
|
|
761
|
-
|
|
784
|
+
enterChain(state, depth, settings.codexTimeoutMs + 5000)
|
|
785
|
+
try {
|
|
786
|
+
return await runCodex({
|
|
762
787
|
bin: resolveCodex(settings.codexBin),
|
|
763
788
|
prompt,
|
|
764
789
|
cwd,
|
|
765
790
|
resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
|
|
766
791
|
sandbox: typeof args?.sandbox === 'string' ? args.sandbox : settings.codexSandbox,
|
|
767
792
|
timeoutMs: settings.codexTimeoutMs,
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
793
|
+
signal: exec?.signal,
|
|
794
|
+
children: state.children,
|
|
795
|
+
})
|
|
796
|
+
} finally {
|
|
797
|
+
leaveChain(state)
|
|
798
|
+
}
|
|
771
799
|
},
|
|
772
800
|
}
|
|
773
801
|
}
|
|
@@ -848,6 +876,91 @@ function collectAssistant(ctx, sessionId, sinceSeq, settings) {
|
|
|
848
876
|
}
|
|
849
877
|
|
|
850
878
|
|
|
879
|
+
/** Deepest cross-tool delegation currently in flight, or 0 when no chain is open. */
|
|
880
|
+
export function ambientDepth(state) {
|
|
881
|
+
const chain = state.chain
|
|
882
|
+
if (chain === undefined || chain.active <= 0) return 0
|
|
883
|
+
return Date.now() < chain.until ? chain.depth : 0
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Mark one outbound delegation as in flight, so anything it triggers inherits its depth.
|
|
888
|
+
* @param state - bridge bookkeeping.
|
|
889
|
+
* @param depth - depth of the run that is starting.
|
|
890
|
+
* @param ttlMs - how long the run may last.
|
|
891
|
+
*/
|
|
892
|
+
export function enterChain(state, depth, ttlMs) {
|
|
893
|
+
const chain = state.chain
|
|
894
|
+
chain.active += 1
|
|
895
|
+
chain.depth = Math.max(chain.depth, depth)
|
|
896
|
+
chain.until = Date.now() + ttlMs
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Release one outbound delegation, closing the chain when the last one ends.
|
|
901
|
+
* @param state - bridge bookkeeping.
|
|
902
|
+
*/
|
|
903
|
+
export function leaveChain(state) {
|
|
904
|
+
const chain = state.chain
|
|
905
|
+
chain.active = Math.max(0, chain.active - 1)
|
|
906
|
+
if (chain.active === 0) {
|
|
907
|
+
chain.depth = 0
|
|
908
|
+
chain.until = 0
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Refuse a delegation that would exceed the configured depth.
|
|
914
|
+
* @param state - bridge bookkeeping.
|
|
915
|
+
* @param settings - resolved settings.
|
|
916
|
+
* @param callerDepth - depth of the caller, when it is known to be a worker.
|
|
917
|
+
* @param action - tool name used in the message.
|
|
918
|
+
* @returns the depth this delegation would create.
|
|
919
|
+
*/
|
|
920
|
+
function guardDepth(state, settings, callerDepth, action) {
|
|
921
|
+
const depth = Math.max(ambientDepth(state), callerDepth ?? 0) + 1
|
|
922
|
+
if (depth > settings.maxHops) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`${action} would create delegation depth ${String(depth)}, but maxHops is ${String(settings.maxHops)}. ` +
|
|
925
|
+
'Carry the work out directly instead of delegating again.',
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
return depth
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Charge one dispatch against the rolling rate limit.
|
|
933
|
+
* @param state - bridge bookkeeping.
|
|
934
|
+
* @param settings - resolved settings.
|
|
935
|
+
*/
|
|
936
|
+
function takeDispatchSlot(state, settings) {
|
|
937
|
+
const now = Date.now()
|
|
938
|
+
const windowMs = settings.dispatchWindowMs
|
|
939
|
+
state.recent = state.recent.filter((time) => now - time < windowMs)
|
|
940
|
+
if (state.recent.length >= settings.dispatchPerMinute) {
|
|
941
|
+
const retrySeconds = Math.max(1, Math.ceil((windowMs - (now - state.recent[0])) / 1000))
|
|
942
|
+
throw new Error(
|
|
943
|
+
`rate limit: ${String(settings.dispatchPerMinute)} dispatches per ${String(Math.round(windowMs / 1000))}s reached; retry in ${String(retrySeconds)}s`,
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
state.recent.push(now)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Depth of a session the bridge already knows: workers start one level down.
|
|
951
|
+
* @param state - bridge bookkeeping.
|
|
952
|
+
* @param sessionId - calling session, when known.
|
|
953
|
+
* @returns 1 for a bridge worker, otherwise 0.
|
|
954
|
+
*/
|
|
955
|
+
function callerDepth(state, sessionId) {
|
|
956
|
+
if (typeof sessionId !== 'string' || sessionId === '') return 0
|
|
957
|
+
for (const record of state.workers.values()) {
|
|
958
|
+
if (record.sessionId === sessionId) return 1
|
|
959
|
+
}
|
|
960
|
+
return 0
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
|
|
851
964
|
/**
|
|
852
965
|
* Restore the worker registry written by a previous process.
|
|
853
966
|
* @param settings - resolved settings.
|
|
@@ -1427,6 +1540,18 @@ function resolveSettings(config) {
|
|
|
1427
1540
|
if (key === 'codexBin' && typeof value === 'string' && value !== '') merged.codexBin = value
|
|
1428
1541
|
if (key === 'codexSandbox' && typeof value === 'string' && value !== '') merged.codexSandbox = value
|
|
1429
1542
|
if (key === 'workersFile' && typeof value === 'string' && value !== '') merged.workersFile = expandHome(value)
|
|
1543
|
+
if (key === 'maxHops') {
|
|
1544
|
+
const hops = Number(value)
|
|
1545
|
+
if (Number.isInteger(hops) && hops >= 0) merged.maxHops = hops
|
|
1546
|
+
}
|
|
1547
|
+
if (key === 'dispatchPerMinute') {
|
|
1548
|
+
const count = Number(value)
|
|
1549
|
+
if (Number.isInteger(count) && count > 0) merged.dispatchPerMinute = count
|
|
1550
|
+
}
|
|
1551
|
+
if (key === 'dispatchWindowMs') {
|
|
1552
|
+
const ms = Number(value)
|
|
1553
|
+
if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
|
|
1554
|
+
}
|
|
1430
1555
|
if (key === 'maxWorkers') {
|
|
1431
1556
|
const count = Number(value)
|
|
1432
1557
|
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.6.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": [
|