dsh-plugin-subagent-bridge 0.5.0 → 0.6.0

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 +120 -4
  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
+ 50 项:握手与版本协商、通知处理、工具清单、未知方法与未知工具、参数校验、元数据列表不含消息内容、派发调用形状、拒绝与错误透出、结果读取的时间基线、未派发会话的拒绝、长轮询(文本晚到即返回 / 超时 / 调用方取消)、真实 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 计入同一预算。
196
223
  `test/persistence.test.mjs` 单独覆盖持久化:文件权限 0600、缺失/损坏/畸形记录、spawn 返回时文件已落盘、完成即缓存、followup 清缓存、close 后重启仍为 closed、冷会话走缓存或 `offline`、并发写不串。
197
224
 
198
225
  ---
package/index.js CHANGED
@@ -58,6 +58,9 @@ const DEFAULTS = {
58
58
  taskTimeoutSeconds: 300,
59
59
  waitTimeoutMs: 30000,
60
60
  maxWorkers: 4,
61
+ maxHops: 1,
62
+ dispatchPerMinute: 10,
63
+ dispatchWindowMs: 60000,
61
64
  workersFile: join(homedir(), '.dsh', 'bridge', 'workers.json'),
62
65
  }
63
66
 
@@ -545,6 +548,8 @@ export function createApi(ctx, state, settings) {
545
548
  state.dispatched = state.dispatched ?? new Set()
546
549
  state.baseline = state.baseline ?? new Map()
547
550
  state.workers = state.workers ?? new Map()
551
+ state.chain = state.chain ?? { depth: 0, active: 0, until: 0 }
552
+ state.recent = state.recent ?? []
548
553
  const controller = () => ctx.get('sessionController')
549
554
  const sessions = () => ctx.get('sessions')
550
555
 
@@ -576,6 +581,8 @@ export function createApi(ctx, state, settings) {
576
581
  const service = controller()
577
582
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
578
583
  if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
584
+ guardDepth(state, settings, 0, 'dsh_spawn')
585
+ takeDispatchSlot(state, settings)
579
586
  assertCapacity(state, settings)
580
587
 
581
588
  let sessionId
@@ -611,6 +618,7 @@ export function createApi(ctx, state, settings) {
611
618
  if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
612
619
  const service = controller()
613
620
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
621
+ takeDispatchSlot(state, settings)
614
622
  const before = Number(sessions()?.get?.(target.sessionId)?.seq ?? 0)
615
623
  const requestId = randomUUID()
616
624
  const accepted = await service.prompt(
@@ -652,6 +660,8 @@ export function createApi(ctx, state, settings) {
652
660
  const service = controller()
653
661
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
654
662
  const seconds = Math.min(600, Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds))
663
+ guardDepth(state, settings, 0, 'dsh_task')
664
+ takeDispatchSlot(state, settings)
655
665
  const started = Date.now()
656
666
 
657
667
  let targetId = sessionId
@@ -723,6 +733,8 @@ export function createApi(ctx, state, settings) {
723
733
  async dispatch({ sessionId, text, mode }) {
724
734
  const service = controller()
725
735
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
736
+ guardDepth(state, settings, 0, 'dsh_dispatch')
737
+ takeDispatchSlot(state, settings)
726
738
  const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
727
739
  const requestId = randomUUID()
728
740
  const result = await service.prompt(
@@ -757,17 +769,24 @@ export function createApi(ctx, state, settings) {
757
769
 
758
770
  async dispatchCodex(args, exec) {
759
771
  const prompt = requireString(args?.prompt, 'prompt')
772
+ const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'codex_dispatch')
773
+ takeDispatchSlot(state, settings)
760
774
  const cwd = await resolveCwd(ctx, args?.cwd, exec)
761
- return runCodex({
775
+ enterChain(state, depth, settings.codexTimeoutMs + 5000)
776
+ try {
777
+ return await runCodex({
762
778
  bin: resolveCodex(settings.codexBin),
763
779
  prompt,
764
780
  cwd,
765
781
  resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
766
782
  sandbox: typeof args?.sandbox === 'string' ? args.sandbox : settings.codexSandbox,
767
783
  timeoutMs: settings.codexTimeoutMs,
768
- signal: exec?.signal,
769
- children: state.children,
770
- })
784
+ signal: exec?.signal,
785
+ children: state.children,
786
+ })
787
+ } finally {
788
+ leaveChain(state)
789
+ }
771
790
  },
772
791
  }
773
792
  }
@@ -848,6 +867,91 @@ function collectAssistant(ctx, sessionId, sinceSeq, settings) {
848
867
  }
849
868
 
850
869
 
870
+ /** Deepest cross-tool delegation currently in flight, or 0 when no chain is open. */
871
+ export function ambientDepth(state) {
872
+ const chain = state.chain
873
+ if (chain === undefined || chain.active <= 0) return 0
874
+ return Date.now() < chain.until ? chain.depth : 0
875
+ }
876
+
877
+ /**
878
+ * Mark one outbound delegation as in flight, so anything it triggers inherits its depth.
879
+ * @param state - bridge bookkeeping.
880
+ * @param depth - depth of the run that is starting.
881
+ * @param ttlMs - how long the run may last.
882
+ */
883
+ export function enterChain(state, depth, ttlMs) {
884
+ const chain = state.chain
885
+ chain.active += 1
886
+ chain.depth = Math.max(chain.depth, depth)
887
+ chain.until = Date.now() + ttlMs
888
+ }
889
+
890
+ /**
891
+ * Release one outbound delegation, closing the chain when the last one ends.
892
+ * @param state - bridge bookkeeping.
893
+ */
894
+ export function leaveChain(state) {
895
+ const chain = state.chain
896
+ chain.active = Math.max(0, chain.active - 1)
897
+ if (chain.active === 0) {
898
+ chain.depth = 0
899
+ chain.until = 0
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Refuse a delegation that would exceed the configured depth.
905
+ * @param state - bridge bookkeeping.
906
+ * @param settings - resolved settings.
907
+ * @param callerDepth - depth of the caller, when it is known to be a worker.
908
+ * @param action - tool name used in the message.
909
+ * @returns the depth this delegation would create.
910
+ */
911
+ function guardDepth(state, settings, callerDepth, action) {
912
+ const depth = Math.max(ambientDepth(state), callerDepth ?? 0) + 1
913
+ if (depth > settings.maxHops) {
914
+ throw new Error(
915
+ `${action} would create delegation depth ${String(depth)}, but maxHops is ${String(settings.maxHops)}. ` +
916
+ 'Carry the work out directly instead of delegating again.',
917
+ )
918
+ }
919
+ return depth
920
+ }
921
+
922
+ /**
923
+ * Charge one dispatch against the rolling rate limit.
924
+ * @param state - bridge bookkeeping.
925
+ * @param settings - resolved settings.
926
+ */
927
+ function takeDispatchSlot(state, settings) {
928
+ const now = Date.now()
929
+ const windowMs = settings.dispatchWindowMs
930
+ state.recent = state.recent.filter((time) => now - time < windowMs)
931
+ if (state.recent.length >= settings.dispatchPerMinute) {
932
+ const retrySeconds = Math.max(1, Math.ceil((windowMs - (now - state.recent[0])) / 1000))
933
+ throw new Error(
934
+ `rate limit: ${String(settings.dispatchPerMinute)} dispatches per ${String(Math.round(windowMs / 1000))}s reached; retry in ${String(retrySeconds)}s`,
935
+ )
936
+ }
937
+ state.recent.push(now)
938
+ }
939
+
940
+ /**
941
+ * Depth of a session the bridge already knows: workers start one level down.
942
+ * @param state - bridge bookkeeping.
943
+ * @param sessionId - calling session, when known.
944
+ * @returns 1 for a bridge worker, otherwise 0.
945
+ */
946
+ function callerDepth(state, sessionId) {
947
+ if (typeof sessionId !== 'string' || sessionId === '') return 0
948
+ for (const record of state.workers.values()) {
949
+ if (record.sessionId === sessionId) return 1
950
+ }
951
+ return 0
952
+ }
953
+
954
+
851
955
  /**
852
956
  * Restore the worker registry written by a previous process.
853
957
  * @param settings - resolved settings.
@@ -1427,6 +1531,18 @@ function resolveSettings(config) {
1427
1531
  if (key === 'codexBin' && typeof value === 'string' && value !== '') merged.codexBin = value
1428
1532
  if (key === 'codexSandbox' && typeof value === 'string' && value !== '') merged.codexSandbox = value
1429
1533
  if (key === 'workersFile' && typeof value === 'string' && value !== '') merged.workersFile = expandHome(value)
1534
+ if (key === 'maxHops') {
1535
+ const hops = Number(value)
1536
+ if (Number.isInteger(hops) && hops >= 0) merged.maxHops = hops
1537
+ }
1538
+ if (key === 'dispatchPerMinute') {
1539
+ const count = Number(value)
1540
+ if (Number.isInteger(count) && count > 0) merged.dispatchPerMinute = count
1541
+ }
1542
+ if (key === 'dispatchWindowMs') {
1543
+ const ms = Number(value)
1544
+ if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
1545
+ }
1430
1546
  if (key === 'maxWorkers') {
1431
1547
  const count = Number(value)
1432
1548
  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.0",
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": [