A soft gate on the main session: reminds it, then refuses once per stretch, to hand work to a subagent; and refuses one Claude subagent per hour past three.

主会话的软闸门:连续自己做太多,就提醒一次、再拦一次;Claude 子代理派得太密,也拦一次。所有拦截都是软的——每个连续段、每个小时窗口最多拦一次,模型重试即放行。
方向一:主会话自己干太多。 按请求计数(一个 turn.step 记 1,同一请求里并发 5 个 Read 仍记 1),只看主会话,不看子代理:
| 请求里有什么 | 对计数 |
|---|---|
派发:Agent、Task、SendMessage、mcp__delegate__delegate_flash/pro | 清零,并给 3 批只读验收宽限 |
执行:Read/Grep/Glob/Bash/PowerShell/Edit/MultiEdit/Write/NotebookEdit/BashOutput/mcp__ptc-run__run_code | +1(宽限期内全是 Read/Grep/Glob 的批次不计) |
簿记:mcp__ccd_turn__update_plan/wrap_up、mcp__ctx-mod__context/history/ledger_append/ledger_read | 不变,也不清零 |
| 其他(WebFetch、浏览器、LSP、AskUserQuestion、纯文本) | 不变,也不清零 |
优先级:派发 > 执行 > 簿记 > 其他,混合批次按最高的算。
remindAt):在 classic.PostToolBatch 里注入一次提醒,本段只提醒一次。denyAt):该请求里的第一个执行类 tool.call 被拒一次,本段只拒一次,重试即放行。session.start/end、任何派发都会清零。claude --version/--print/--doctor、--inspect、debugpy/gdb/lldb、-it/--interactive、chrome/msedge/playwright 这类 Bash 或 run_code 输入,以及本来就不在执行类里的浏览器、LSP、AskUserQuestion。方向二:Claude 子代理派得太密。 统计主会话调用 Agent/Task 的次数,滚动 1 小时窗口内超过 agentPerHour(默认 3)时,对下一次派发软拦截一次:拒绝理由里要求先按文件集合合并分包;确实不能合并的,重新调用并在 prompt 开头写明为什么不能合并。重试即放行,同一小时内不再拦。派给 DS(mcp__delegate__delegate_flash/pro)不计入这一侧、不设上限,并且也会清零"自己干"的计数。
安装时的设置页或 /plugin 的配置菜单里改:
| 项 | 默认 | 说明 |
|---|---|---|
mode | soft | off 什么都不做;remind 只提醒不拦;soft 提醒 + 两处软拦截 |
remindAt | 4 | 连续第几个执行请求后提醒 |
denyAt | 8 | 连续第几个执行请求后拦一次 |
agentPerHour | 3 | 一小时内允许派几个 Claude 子代理 |
grace | 3 | 派发之后几批只读验收不计 |
环境变量(启动 Claude Code 时给):DISPATCH_GATE=0 整段关掉,DISPATCH_GATE_MODE=off|remind|soft 覆盖 mode。
一个连续段最多拒一个批次,被拒后下一批一定放行;一个小时窗口最多拒一次派发,重试一定放行;派发或用户消息立即清零;子代理自己的调用一律不管;hook 抛错一律走引擎原路径(fail-open)。状态只在内存里,重启即清空,重启后只会更宽松。
/plugin install dispatch-gate --marketplace wdsewdser-bit/claude-code-mods
claude plugin validate dispatch-gate --strict
claude plugin test dispatch-gate
离线回放(不调模型、不联网,只读转录):
node dispatch-gate/scripts/replay.ts [--mode off|remind|soft] [--remind-at N] [--deny-at N] [--agent-per-hour N] [--json] <transcript.jsonl>...
它按主会话的请求走同一套状态机,打印 L1 提醒、L2 拦截和每次 Claude 子代理派发(被拒的标 REFUSED)。子代理自己的转录在 sidechain 文件里,所以主会话转录正好是主线程的请求。
mode=off、DISPATCH_GATE=0、/plugin disable dispatch-gate,或者直接删掉这个目录。不改 ctx-mod、不改 CLAUDE.md。
hooks/register.ts 152 lines1// The engine wiring of dispatch-gate. Every decision lives in `lib/gate.ts`;
2// this file only observes the engine, hands it the request or the call, and
3// answers. Nothing here can throw into the engine: a broken hook walks the
4// engine's own path, so the gate fails open.
5
6import type { EngineInterface, PluginOptions, Register } from 'claude-code'
7
8import {
9 DEFAULTS,
10 createState,
11 judge,
12 observeRequest,
13 readConfig,
14 remindText,
15 resetStreak,
16 usesOf,
17 withEnv,
18} from './lib/gate.ts'
19import type { Config, State } from './lib/gate.ts'
20
21/** The options the engine handed over at load, before the environment's own switch. */
22let base: Config = DEFAULTS
23/** What this session runs with. */
24let config: Config = DEFAULTS
25/** The streak and the hour window of this session. */
26let state: State = createState()
27/** The L1 reminder earned by the last main request, waiting for its batch to end. */
28let pendingRemind = false
29
30const messageOf = (problem: unknown): string => (problem instanceof Error ? problem.message : String(problem))
31
32const attempt = async <T>(run: () => Promise<T>, fallback: T): Promise<T> => {
33 try {
34 return await run()
35 } catch {
36 return fallback
37 }
38}
39
40/** A log that fails must not become the failure. */
41const debug = ($: EngineInterface, text: string): void => {
42 try {
43 $.ui.log(`dispatch-gate: ${text}`, { to: 'debug' })
44 } catch {
45 // best effort
46 }
47}
48
49/** The session's own configuration: the options, then the environment's switch. */
50const configure = async ($: EngineInterface): Promise<Config> => {
51 const gate = await attempt(() => $.env.get('DISPATCH_GATE'), undefined)
52 const mode = await attempt(() => $.env.get('DISPATCH_GATE_MODE'), undefined)
53
54 return withEnv(base, gate, mode)
55}
56
57export const register: Register = (on, options) => {
58 base = readConfig(options as unknown as Readonly<Record<string, unknown>>)
59
60 on('session.start', async ($, e, next) => {
61 state = createState()
62 pendingRemind = false
63 config = await configure($)
64 debug($, `mode=${config.mode} remindAt=${config.remindAt} denyAt=${config.denyAt} agentPerHour=${config.agentPerHour} grace=${config.grace}`)
65
66 return next(e)
67 }).catch(($, e, next) => next(e))
68
69 on('session.end', async ($, e, next) => {
70 state = createState()
71 pendingRemind = false
72
73 return next(e)
74 }).catch(($, e, next) => next(e))
75
76 // A person's own message starts a new request: whatever the model was doing
77 // on its own is over, and the gate's count goes with it.
78 on('prompt.submit', async ($, e, next) => {
79 resetStreak(state, config)
80 pendingRemind = false
81
82 return next(e)
83 }).catch(($, e, next) => next(e))
84
85 // Counting happens at the request, not at the tool call: the model's answer
86 // says what this request is for, and one request is one unit however many
87 // tools it calls in parallel.
88 on('turn.step', async function* ($, e, next) {
89 const result = yield* next(e)
90 try {
91 if (e.agentId === undefined && config.mode !== 'off') {
92 const outcome = observeRequest(state, config, usesOf(result), `${e.turnId}:${e.index}`)
93 if (outcome.remind) {
94 pendingRemind = true
95 debug($, `remind at streak ${state.streak}`)
96 }
97 }
98 } catch (error) {
99 debug($, `turn.step: ${messageOf(error)}`)
100 }
101
102 return result
103 })
104
105 // L1: one reminder per streak, injected once for the whole batch. It cannot
106 // refuse anything, which is why the refusal lives at the tool call.
107 on('classic.PostToolBatch', async ($, e, next) => {
108 const forMain = (e as { agent_id?: unknown }).agent_id === undefined
109 const remind = pendingRemind && forMain
110 if (remind) pendingRemind = false
111 const result = await next(e)
112 if (!remind) return result
113 try {
114 const held = (result ?? {}) as { hookSpecificOutput?: Record<string, unknown> }
115
116 return {
117 ...held,
118 hookSpecificOutput: {
119 ...(held.hookSpecificOutput ?? {}),
120 hookEventName: 'PostToolBatch',
121 additionalContext: remindText(state.streak),
122 },
123 }
124 } catch (error) {
125 debug($, `PostToolBatch: ${messageOf(error)}`)
126
127 return result
128 }
129 }).catch(($, e, next) => next(e))
130
131 // Both refusals live here: the one Claude subagent per hour window, and the
132 // one execution call per streak. A subagent's own calls are never gated.
133 on('tool.call', async ($, e, next) => {
134 try {
135 if (e.agentId !== undefined) return next(e)
136 if (config.mode === 'off') return next(e)
137 const tool = String(e.tool)
138 const now = await attempt(() => $.clock.now(), Date.now())
139 const verdict = judge(state, config, tool, e as unknown as Readonly<Record<string, unknown>>, now)
140 if (verdict.deny !== undefined) {
141 debug($, `deny ${tool} (streak ${state.streak}, agents ${state.agentCalls.length})`)
142
143 return { deny: verdict.deny }
144 }
145 } catch (error) {
146 debug($, `tool.call: ${messageOf(error)}`)
147 }
148
149 return next(e)
150 }).catch(($, e, next) => next(e))
151}
152hooks/lib/gate.ts 352 lines1/**
2 * The decisions of dispatch-gate, with no engine imports: the unit tests and the
3 * offline replay drive this file directly.
4 *
5 * Counting is per request, not per tool call: one model request (one batch of
6 * tool calls, however many) is one unit, because the cost of a request is the
7 * whole prefix it carries.
8 *
9 * Two directions, both soft:
10 * - the main session doing too much itself: consecutive execution requests earn
11 * one reminder (L1) and, later, one refusal (L2) per streak;
12 * - the main session dispatching Claude subagents too fast: more than
13 * `agentPerHour` in a rolling hour earns one refusal per hour window.
14 * A dispatch of either kind clears the self-work streak. Refusals never repeat
15 * inside their own stretch: the model's retry always runs.
16 */
17
18export type Mode = 'off' | 'remind' | 'soft'
19
20export type Config = {
21 /** `off`: nothing; `remind`: only the L1 reminder; `soft`: L1 and both refusals. */
22 mode: Mode
23 /** Consecutive execution requests that earn the reminder, once per streak. */
24 remindAt: number
25 /** Consecutive execution requests that earn the refusal, once per streak. */
26 denyAt: number
27 /** Claude subagent dispatches allowed in one rolling hour. */
28 agentPerHour: number
29 /** Read-only acceptance requests after a dispatch that are free. */
30 grace: number
31}
32
33export const DEFAULTS: Config = { mode: 'soft', remindAt: 4, denyAt: 8, agentPerHour: 3, grace: 3 }
34
35/** The rolling window of the Claude-subagent limit. */
36export const AGENT_WINDOW_MS = 3_600_000
37
38/** One tool use of a request: the tool's name and the arguments it was given. */
39export type Use = { tool: string; input: Readonly<Record<string, unknown>> }
40
41/** A request's class; the highest present wins: dispatch > exec > book > other. */
42export type Kind = 'dispatch' | 'exec' | 'book' | 'other'
43
44/** Work the main session does itself. */
45const EXEC = new Set([
46 'Read',
47 'Grep',
48 'Glob',
49 'Bash',
50 'PowerShell',
51 'Edit',
52 'MultiEdit',
53 'Write',
54 'NotebookEdit',
55 'BashOutput',
56 'mcp__ptc-run__run_code',
57])
58
59/** Reading the work back after a dispatch: free while the acceptance grace lasts. */
60const READ_ONLY = new Set(['Read', 'Grep', 'Glob'])
61
62/** The tools that start or resume a Claude subagent. */
63const CLAUDE_AGENT = new Set(['Agent', 'Task'])
64
65/** Dispatching to DeepSeek: never limited, and it clears the self-work count. */
66const DELEGATE = new Set(['mcp__delegate__delegate_flash', 'mcp__delegate__delegate_pro'])
67
68/** The host's own bookkeeping: neither counted nor cleared. */
69const BOOK = new Set([
70 'mcp__ccd_turn__update_plan',
71 'mcp__ccd_turn__wrap_up',
72 'mcp__ctx-mod__context',
73 'mcp__ctx-mod__history',
74 'mcp__ctx-mod__ledger_append',
75 'mcp__ctx-mod__ledger_read',
76])
77
78/** The tools whose input may be a main-session-only operation. */
79const PROBE = new Set(['Bash', 'PowerShell', 'mcp__ptc-run__run_code'])
80
81/**
82 * A command that only the main session can run: a Claude probe, an interactive
83 * debugger, or a browser. It is never counted and never refused.
84 */
85const MAIN_ONLY: readonly RegExp[] = [
86 /\bclaude\b[^\n]*?(?:\s-p\b|\s--print\b|--version\b|--doctor\b)/,
87 /--inspect(?:-brk)?\b/,
88 /\bdebugpy\b/,
89 /\bgdb\b/,
90 /\blldb\b/,
91 /\s-it\b/,
92 /--interactive\b/,
93 /\b(?:chrome|msedge|playwright)\b/,
94]
95
96export const isExecTool = (tool: string): boolean => EXEC.has(tool)
97export const isClaudeAgent = (tool: string): boolean => CLAUDE_AGENT.has(tool)
98export const isDelegate = (tool: string): boolean => DELEGATE.has(tool)
99export const isBookTool = (tool: string): boolean => BOOK.has(tool)
100export const isDispatchTool = (tool: string): boolean =>
101 isClaudeAgent(tool) || isDelegate(tool) || tool === 'SendMessage'
102
103/** Whether this tool use is a main-session-only operation, from its command or code. */
104export const isMainOnly = (use: Use): boolean => {
105 if (!PROBE.has(use.tool)) return false
106 const command = use.input['command']
107 const code = use.input['code']
108 const text = typeof command === 'string' ? command : typeof code === 'string' ? code : ''
109 if (text === '') return false
110
111 return MAIN_ONLY.some(pattern => pattern.test(text))
112}
113
114/** Whether this tool use is work the main session does itself. */
115export const isExec = (use: Use): boolean => EXEC.has(use.tool) && !isMainOnly(use)
116
117/** The class of one request, from the tool uses the model answered with. */
118export const classify = (uses: readonly Use[]): Kind => {
119 if (uses.some(use => isDispatchTool(use.tool))) return 'dispatch'
120 if (uses.some(use => isExec(use))) return 'exec'
121 if (uses.some(use => isBookTool(use.tool))) return 'book'
122
123 return 'other'
124}
125
126/** The tool uses of a `turn.step` result, however the engine spells them. */
127export const usesOf = (result: unknown): Use[] => {
128 const held = (result as { toolUses?: unknown } | null | undefined)?.toolUses
129 if (!Array.isArray(held)) return []
130 const uses: Use[] = []
131 for (const item of held) {
132 if (item === null || typeof item !== 'object') continue
133 const row = item as Record<string, unknown>
134 const name = typeof row['tool'] === 'string' ? row['tool'] : typeof row['name'] === 'string' ? row['name'] : undefined
135 if (name === undefined) continue
136 const input = row['input']
137 uses.push({
138 tool: name,
139 input: input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {},
140 })
141 }
142
143 return uses
144}
145
146export type State = {
147 /** Consecutive execution requests the main session did itself. */
148 streak: number
149 /** Read-only acceptance requests left free after the last dispatch. */
150 grace: number
151 /** The reminder was given in this streak. */
152 reminded: boolean
153 /** The refusal was given in this streak. */
154 denied: boolean
155 /** The next execution tool call is refused. */
156 armed: boolean
157 /** When each Claude subagent was dispatched, in ms. */
158 agentCalls: number[]
159 /** When this hour window gave its one refusal; null when it has not. */
160 agentDeniedAt: number | null
161 /** The last request counted, so the same step seen twice counts once. */
162 lastStep: string | null
163}
164
165export const createState = (): State => ({
166 streak: 0,
167 grace: 0,
168 reminded: false,
169 denied: false,
170 armed: false,
171 agentCalls: [],
172 agentDeniedAt: null,
173 lastStep: null,
174})
175
176/** A dispatch, or a person's own message: the streak starts over, with fresh grace. */
177export const resetStreak = (state: State, config: Config): void => {
178 state.streak = 0
179 state.reminded = false
180 state.denied = false
181 state.armed = false
182 state.grace = Math.max(0, Math.floor(config.grace))
183}
184
185export type Outcome = {
186 kind: Kind
187 /** The L1 reminder is owed for this request. */
188 remind: boolean
189}
190
191/**
192 * One main-session request, after its answer is known. `stepKey` (turn and
193 * index) keeps a replayed or retried step from counting twice.
194 */
195export const observeRequest = (
196 state: State,
197 config: Config,
198 uses: readonly Use[],
199 stepKey?: string,
200): Outcome => {
201 if (stepKey !== undefined) {
202 if (state.lastStep === stepKey) return { kind: 'other', remind: false }
203 state.lastStep = stepKey
204 }
205 const kind = classify(uses)
206 if (kind === 'dispatch') {
207 resetStreak(state, config)
208
209 return { kind, remind: false }
210 }
211 // Bookkeeping and everything else: neither counted nor cleared, so a plan
212 // update in the middle of a long stretch does not buy the model a fresh one.
213 if (kind !== 'exec') return { kind, remind: false }
214
215 // Acceptance: after a dispatch, reading the result back is free for a few requests.
216 if (state.grace > 0 && uses.every(use => !isExec(use) || READ_ONLY.has(use.tool))) {
217 state.grace -= 1
218
219 return { kind, remind: false }
220 }
221 state.grace = 0
222 state.streak += 1
223
224 let remind = false
225 if (config.mode !== 'off' && state.streak >= config.remindAt && !state.reminded) {
226 state.reminded = true
227 remind = true
228 }
229 if (config.mode === 'soft' && state.streak >= config.denyAt && !state.denied) {
230 state.denied = true
231 state.armed = true
232 }
233
234 return { kind, remind }
235}
236
237export type Verdict = { deny?: string }
238
239/**
240 * One main-session tool call, before it runs. A refusal is returned instead of
241 * the call's result; it is given at most once per streak and once per hour
242 * window, so the model's retry always runs.
243 */
244export const judge = (
245 state: State,
246 config: Config,
247 tool: string,
248 input: Readonly<Record<string, unknown>>,
249 now: number,
250): Verdict => {
251 if (config.mode === 'off') return {}
252
253 // DeepSeek: unlimited, and it clears the self-work count.
254 if (isDelegate(tool)) {
255 resetStreak(state, config)
256
257 return {}
258 }
259
260 // Claude subagents: counted in their own rolling window, and they also clear
261 // the self-work count, since dispatching is exactly what this mod wants.
262 if (isClaudeAgent(tool)) {
263 if (config.mode !== 'soft') {
264 resetStreak(state, config)
265
266 return {}
267 }
268 const refusal = judgeAgentWindow(state, config, now)
269 if (refusal !== undefined) return { deny: refusal }
270 resetStreak(state, config)
271
272 return {}
273 }
274
275 if (tool === 'SendMessage') {
276 resetStreak(state, config)
277
278 return {}
279 }
280
281 if (config.mode === 'soft' && state.armed && isExec({ tool, input })) {
282 state.armed = false
283
284 return { deny: denyText(state.streak) }
285 }
286
287 return {}
288}
289
290/** The Claude-subagent limit: one refusal per rolling hour window. */
291const judgeAgentWindow = (state: State, config: Config, now: number): string | undefined => {
292 state.agentCalls = state.agentCalls.filter(at => now - at < AGENT_WINDOW_MS)
293 if (state.agentDeniedAt !== null && now - state.agentDeniedAt >= AGENT_WINDOW_MS) state.agentDeniedAt = null
294
295 if (state.agentCalls.length >= Math.max(0, Math.floor(config.agentPerHour)) && state.agentDeniedAt === null) {
296 state.agentDeniedAt = now
297
298 return agentDenyText(state.agentCalls.length)
299 }
300 state.agentCalls.push(now)
301
302 return undefined
303}
304
305/** L1: the reminder injected once per streak, after the batch that earned it. */
306export const remindText = (streak: number): string =>
307 `dispatch-gate:主会话已连续 ${streak} 个执行请求,没有派发。接下来把剩下的工作按文件集合打包,交给 mcp__delegate__delegate_pro(mode=work),写清要改的文件、验收标准和不要碰的文件;主会话只做派发和验收。探针、浏览器、交互式调试这类只能自己做的,继续即可。`
308
309/** L2: the one refusal per streak, given at the tool call that would keep going. */
310export const denyText = (streak: number): string =>
311 `dispatch-gate:这一段已连续 ${streak} 个执行请求,先停一下。把接下来的工作按文件集合合并成 1–2 个工作包,用 mcp__delegate__delegate_pro(mode=work)派出去,写清要改的文件、验收标准、不要碰的文件。确实只能主会话做的,重新调用同一个工具即可放行;本段不会再拦。`
312
313/** The refusal of a Claude subagent dispatched past the hourly limit. */
314export const agentDenyText = (count: number): string =>
315 `dispatch-gate:最近 60 分钟已经派了 ${count} 个 Claude 子代理。先按文件集合合并分包:改同一批文件、或验收标准相同的合成一个。确实不能合并的,重新调用 Agent,并在 prompt 开头写明为什么不能合并、拆成几包、每包改哪些文件。重试会放行,这一小时内不再拦。`
316
317export const modeOf = (value: unknown, fallback: Mode = DEFAULTS.mode): Mode => {
318 const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
319
320 return text === 'off' || text === 'remind' || text === 'soft' ? text : fallback
321}
322
323const numberOf = (value: unknown, fallback: number): number => {
324 const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
325
326 return Number.isFinite(n) ? n : fallback
327}
328
329/** The plugin's options, as the engine hands them over. */
330export const readConfig = (options: Readonly<Record<string, unknown>> | undefined): Config => {
331 const held = options ?? {}
332
333 return {
334 mode: modeOf(held['mode']),
335 remindAt: Math.max(1, Math.floor(numberOf(held['remindAt'], DEFAULTS.remindAt))),
336 denyAt: Math.max(1, Math.floor(numberOf(held['denyAt'], DEFAULTS.denyAt))),
337 agentPerHour: Math.max(0, Math.floor(numberOf(held['agentPerHour'], DEFAULTS.agentPerHour))),
338 grace: Math.max(0, Math.floor(numberOf(held['grace'], DEFAULTS.grace))),
339 }
340}
341
342/**
343 * The environment's own switch, read once per session: `DISPATCH_GATE=0` turns
344 * the mod off, `DISPATCH_GATE_MODE=off|remind|soft` overrides the option.
345 */
346export const withEnv = (config: Config, gate: string | undefined, mode: string | undefined): Config => {
347 if (gate === '0') return { ...config, mode: 'off' }
348 if (typeof mode === 'string' && mode.trim() !== '') return { ...config, mode: modeOf(mode, config.mode) }
349
350 return config
351}
352