SLOPSHOPPER

dispatch-gate

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.

newguardprompt
★ 3v0.1.0MITupdated 2026-10-22wdsewdser-bit/claude-code-mods/dispatch-gate
A shopper browsing a rack in a slop shop
README

dispatch-gate

主会话的软闸门:连续自己做太多,就提醒一次、再拦一次;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、纯文本)不变,也不清零

优先级:派发 > 执行 > 簿记 > 其他,混合批次按最高的算。

  • 第 4 个连续执行请求(remindAt):在 classic.PostToolBatch 里注入一次提醒,本段只提醒一次。
  • 第 8 个连续执行请求(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 的配置菜单里改:

项默认说明
modesoftoff 什么都不做;remind 只提醒不拦;soft 提醒 + 两处软拦截
remindAt4连续第几个执行请求后提醒
denyAt8连续第几个执行请求后拦一次
agentPerHour3一小时内允许派几个 Claude 子代理
grace3派发之后几批只读验收不计

环境变量(启动 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。

Source 2 files
hooks/register.ts 152 lines
1// 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}
152
hooks/lib/gate.ts 352 lines
1/**
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