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.
Files changed (3) hide show
  1. package/README.md +28 -1
  2. package/index.js +131 -6
  3. 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
- 45 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 HTTP 往返(含 404/405/202 与 bearer 分支)、`codex exec` 事件解析、二进制解析、成功/失败/超时三种运行结果、resume 的参数分叉、工具结果的 lossless JSON 约束、**只回最终回答**(多步叙述被丢弃)、`dsh_wait` 的任一完成 / 超时 / 错误 / 未派发四种状态、`dsh_spawn` 立即返回与注册、`fork_turns=all` 走 fork、并发上限与 `dsh_close` 释放名额、`dsh_followup` 的重基线与最终回答切换、注册表落盘与重启后复原、冷会话下的缓存回答与 `offline` 状态、注册表 200 条上限的淘汰顺序。
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: '0.5.0' },
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
- return runCodex({
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
- signal: exec?.signal,
769
- children: state.children,
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.5.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": [