Nudges the main agent to delegate to subagents when it pulls too much material into its own context, and records nudge stats per session.

A Claude Code mod that nudges the main agent to hand work to subagents when it pulls too much material into its own context. It watches the main thread for four signals, and when one trips it appends a short reminder to the tool result the model is already reading. It never blocks a tool call.
Grep, Glob, and read-only Bash using grep, rg, ag, find, fd, git grep, git ls-files, or ls -R). Chains such as cd x && rg foo count; piped | grep filters do notEdit, Write, NotebookEdit) across 3 or more distinct filesAgent/Task call is dispatched with no model: and a generic (general-purpose or unset) subagent type.dg <output>·<files>f·<searches>s·<delegations>↗, for example dg 12k·3f·2s·1↗ (output is shown in k chars once it reaches 1000).Agent dispatch in the same turn.1. From the marketplace
/plugin install delegate-guard --marketplace srepollock/dotfiles
Answer y to add the marketplace, then pick a scope (user scope = every session). Hooks-only mods are active immediately.
2. Via the dotfiles repo
Clone srepollock/dotfiles and run .dotfile_scripts/claude_sync pull. It copies .claude/skills/delegate-guard/ into ~/.claude/skills/, where a plugin folder auto-loads in every new session.
3. Ad hoc
claude --plugin-dir /path/to/delegate-guard
Or list the absolute path in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
| Command | Effect |
|---|---|
/delegate-guard | Show on/off state, this turn's status line, thresholds and usage |
/delegate-guard on | Enable nudges for this session |
/delegate-guard off | Disable nudges for this session and clear the status line |
/delegate-guard stats [n] | Table of the last n sessions (default 10) plus totals |
on/off override the enabled option for the current session only. stats prints per-session turns, nudges, nudged turns, followed turns, delegations and peak output, then:
nudge-follow rate: 50% (3/6)
nudges per turn: 0.40
Nudge-follow rate is nudged turns that later dispatched an agent, divided by nudged turns.
Editable in the plugin config menu (/plugin configure delegate-guard@srepollock-dotfiles) or under pluginConfigs.delegate-guard in settings. The installer may report these options as "not yet set". That's fine: any option left unset uses the default below. Thresholds are read when the plugin loads.
| Option | Type | Default | Meaning |
|---|---|---|---|
enabled | boolean | true | Nudge the main agent to delegate |
outputCharThreshold | number | 40000 | Volume nudge fires when read-only output in a turn exceeds this many characters |
distinctFileThreshold | number | 6 | Breadth nudge when this many distinct files are read in a turn |
searchCallThreshold | number | 6 | Search nudge after this many search calls in a turn |
editFileThreshold | number | 3 | Edits nudge when this many distinct files are edited in a turn |
Its reminders assume a delegation policy with model tiers (haiku for sweeps, sonnet for well-specified implementation, inherit for frontier work), such as the "Delegation & Model Tiers" section of this repo's .claude/CLAUDE.md.
claude plugin validate <folder>
claude plugin test <folder>
Files are hot-reloaded when the mod is loaded from ~/.claude/skills/ or --plugin-dir in an interactive session.
Claude Code with the mod (function-hook plugin) API; built and tested against Claude Code 2.1.291.
hooks/register.ts 170 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import {
5 MAX_SESSIONS,
6 formatStats,
7 formatStatus,
8 initialTurnState,
9 isMainLoop,
10 observe,
11 upsertSession,
12} from './guard'
13import type { GuardCall, Thresholds } from './guard'
14import type { SessionRecord } from '../types'
15
16const COMMAND = 'delegate-guard'
17const SESSIONS_KEY = 'sessions'
18const DEFAULT_STATS_COUNT = 10
19
20const turn = atom({ plugin: 'delegate-guard', key: 'turn' } as const, initialTurnState())
21const isEnabled = atom({ plugin: 'delegate-guard', key: 'isEnabled' } as const, null)
22const session = atom({ plugin: 'delegate-guard', key: 'session' } as const, null)
23
24const USAGE = [
25 'usage:',
26 ' /delegate-guard on enable nudges for this session',
27 ' /delegate-guard off disable nudges for this session',
28 ' /delegate-guard stats [n] last n sessions (default 10) and totals',
29].join('\n')
30
31function numberOption(options: PluginOptions, key: string, fallback: number): number {
32 const value = options[key]
33 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
34}
35
36function thresholdsFrom(options: PluginOptions): Thresholds {
37 return {
38 outputChars: numberOption(options, 'outputCharThreshold', 40_000),
39 distinctFiles: numberOption(options, 'distinctFileThreshold', 6),
40 searchCalls: numberOption(options, 'searchCallThreshold', 6),
41 editFiles: numberOption(options, 'editFileThreshold', 3),
42 }
43}
44
45async function isActive($: EngineInterface, options: PluginOptions): Promise<boolean> {
46 const runtime = await read($, isEnabled)
47 return runtime ?? options.enabled !== false
48}
49
50function text(value: unknown): string | undefined {
51 return typeof value === 'string' ? value : undefined
52}
53
54function toCall(e: Readonly<Record<string, unknown>>, answer: { text?: string; isReadOnly?: true }): GuardCall {
55 const tool = String(e.tool)
56 return {
57 tool,
58 filePath: text(e.file_path) ?? text(e.notebook_path),
59 command: text(e.command),
60 model: text(e.model),
61 subagentType: text(e.subagent_type),
62 isReadOnly: answer.isReadOnly === true,
63 outputChars: answer.text?.length ?? 0,
64 }
65}
66
67async function readSessions($: EngineInterface): Promise<SessionRecord[]> {
68 const stored = await $.store.get(SESSIONS_KEY)
69 return Array.isArray(stored) ? (stored as SessionRecord[]) : []
70}
71
72async function statsText($: EngineInterface, args: readonly string[]): Promise<string> {
73 const requested = Number.parseInt(args[1] ?? '', 10)
74 const count = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_STATS_COUNT
75 return formatStats(await readSessions($), count)
76}
77
78export const register: Register = (on, options) => {
79 const thresholds = thresholdsFrom(options)
80
81 on('session.start', async ($, e, next) => {
82 const result = await next(e)
83 await $.command.register({
84 name: COMMAND,
85 description: 'Delegation nudges: toggle for this session or show nudge stats',
86 argumentHint: '[on|off|stats [n]]',
87 })
88 const at = await $.clock.now()
89 await update($, session, () => ({ id: String(at), startedAt: at, cwd: e.cwd }))
90 await update($, turn, () => initialTurnState())
91 return result
92 })
93
94 on('prompt.submit', async ($, e, next) => {
95 const result = await next(e)
96 const isUserPrompt = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
97 if (result.drop === undefined && isUserPrompt) {
98 await update($, turn, () => initialTurnState())
99 }
100 return result
101 }).catch(($, e, next) => next(e))
102
103 on('tool.call', async ($, e, next) => {
104 if (!isMainLoop(e) || !(await isActive($, options))) return next(e)
105
106 const answer = await next(e)
107 if (answer.deny !== undefined) return answer
108
109 const call = toCall(e, answer)
110 let reminders: string[] = []
111 let status = ''
112 await update($, turn, current => {
113 const observed = observe(current, call, thresholds)
114 reminders = observed.reminders
115 status = formatStatus(observed.state)
116 return observed.state
117 })
118 if (status !== formatStatus(initialTurnState()) || reminders.length > 0) $.ui.status(status)
119
120 if (reminders.length === 0) return answer
121 return { ...answer, context: [...(answer.context ?? []), ...reminders] }
122 }).catch(($, e, next) => next(e))
123
124 on('turn.complete', async ($, e, next) => {
125 const result = await next(e)
126 if (!isMainLoop(e) || !(await isActive($, options))) return result
127
128 const finished = await read($, turn)
129 const identity = (await read($, session)) ?? {
130 id: String(await $.clock.now()),
131 startedAt: await $.clock.now(),
132 cwd: null,
133 }
134 await update($, session, () => identity)
135 const sessions = await readSessions($)
136 await $.store.set(SESSIONS_KEY, upsertSession(sessions, identity, finished, MAX_SESSIONS))
137 await update($, turn, () => initialTurnState())
138 return result
139 })
140
141 on('command.run', { command: COMMAND }, async ($, e) => {
142 const args = e.args.trim().split(/\s+/).filter(Boolean)
143 const verb = args[0]
144
145 if (verb === 'on') {
146 await update($, isEnabled, () => true)
147 return { text: 'delegate-guard: on for this session.' }
148 }
149 if (verb === 'off') {
150 await update($, isEnabled, () => false)
151 $.ui.status(undefined)
152 return { text: 'delegate-guard: off for this session.' }
153 }
154 if (verb === 'stats') {
155 return { text: await statsText($, args) }
156 }
157
158 const active = await isActive($, options)
159 const current = await read($, turn)
160 const lines = [
161 `delegate-guard: ${active ? 'on' : 'off'}`,
162 `this turn: ${formatStatus(current)}`,
163 `thresholds: ${thresholds.outputChars} chars, ${thresholds.distinctFiles} files, ${thresholds.searchCalls} searches, ${thresholds.editFiles} edited files`,
164 '',
165 USAGE,
166 ]
167 return { text: lines.join('\n') }
168 })
169}
170hooks/guard.ts 242 lines1import type { FiredKind, SessionIdentity, SessionRecord, TurnState } from '../types'
2
3export type Thresholds = {
4 outputChars: number
5 distinctFiles: number
6 searchCalls: number
7 editFiles: number
8}
9
10export type GuardCall = {
11 tool: string
12 filePath?: string
13 command?: string
14 model?: string
15 subagentType?: string
16 isReadOnly: boolean
17 outputChars: number
18}
19
20export type Observation = {
21 state: TurnState
22 reminders: string[]
23}
24
25export const MAX_SESSIONS = 50
26export const KINDS: readonly FiredKind[] = ['volume', 'breadth-files', 'breadth-search', 'edits']
27
28const READ_ONLY_BY_NATURE = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch'])
29const SEARCH_TOOLS = new Set(['Grep', 'Glob'])
30const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
31const DELEGATE_TOOLS = new Set(['Agent', 'Task'])
32const SEARCH_COMMAND = /^(grep|rg|ag|find|fd|git\s+grep|git\s+ls-files|ls\s+-[a-zA-Z]*R)\b/
33
34// Checks each `&&`/`;`/`||`-chained segment so `cd repo && rg foo` counts; a pipe
35// stays one segment, since `... | grep` filters output rather than sweeping files.
36function isSearchCommand(command: string): boolean {
37 return command.split(/&&|\|\||;/).some(segment => SEARCH_COMMAND.test(segment.trim()))
38}
39
40export const MODEL_REMINDER =
41 'delegate-guard: Agent dispatched without `model:` — set it per the tier table (haiku for sweeps/extraction, sonnet for well-specified implementation, inherit only for frontier work).'
42
43export function initialTurnState(): TurnState {
44 return {
45 outputChars: 0,
46 filesRead: [],
47 searchCalls: 0,
48 filesEdited: [],
49 firedKinds: [],
50 nudged: false,
51 delegations: 0,
52 followed: false,
53 }
54}
55
56export function isMainLoop(e: { agentId?: string }): boolean {
57 return e.agentId === undefined
58}
59
60function withPath(paths: readonly string[], path: string | undefined): string[] {
61 if (path === undefined || path === '' || paths.includes(path)) return [...paths]
62 return [...paths, path]
63}
64
65function thousands(chars: number): string {
66 return `${Math.round(chars / 1000)}k`
67}
68
69function reminderFor(kind: FiredKind, state: TurnState): string {
70 switch (kind) {
71 case 'volume':
72 return `delegate-guard: ~${thousands(state.outputChars)} chars of tool output in the main thread this turn. If what remains is extraction or a sweep, hand it to a haiku/sonnet subagent and keep only its conclusion. If this is live debugging that needs this context, continue inline.`
73 case 'breadth-files':
74 return `delegate-guard: ${state.filesRead.length} distinct files read directly this turn. Delegate further exploration to an Explore subagent (model: haiku) with a precise question; keep the conclusion, not the file dumps.`
75 case 'breadth-search':
76 return `delegate-guard: ${state.searchCalls} search calls (Grep/Glob/find) run directly this turn. Delegate the remaining sweep to an Explore subagent (model: haiku) with a precise question; keep the conclusion, not the match lists.`
77 case 'edits':
78 return `delegate-guard: edits now span ${state.filesEdited.length} files this turn. If the remaining implementation is well-specified, hand it to a sonnet implementer with a written brief (goal, files, constraints, return shape).`
79 }
80}
81
82function crossed(kind: FiredKind, state: TurnState, thresholds: Thresholds): boolean {
83 switch (kind) {
84 case 'volume':
85 return state.outputChars > thresholds.outputChars
86 case 'breadth-files':
87 return state.filesRead.length >= thresholds.distinctFiles
88 case 'breadth-search':
89 return state.searchCalls >= thresholds.searchCalls
90 case 'edits':
91 return state.filesEdited.length >= thresholds.editFiles
92 }
93}
94
95export function observe(state: TurnState, call: GuardCall, thresholds: Thresholds): Observation {
96 const reminders: string[] = []
97 let next: TurnState = { ...state }
98
99 if (DELEGATE_TOOLS.has(call.tool)) {
100 next = { ...next, delegations: next.delegations + 1, followed: next.followed || next.nudged }
101 const hasNoModel = call.model === undefined || call.model === ''
102 const isGeneric = call.subagentType === undefined || call.subagentType === 'general-purpose'
103 if (hasNoModel && isGeneric) reminders.push(MODEL_REMINDER)
104 } else {
105 if (call.tool === 'Read') {
106 next = { ...next, filesRead: withPath(next.filesRead, call.filePath) }
107 } else if (SEARCH_TOOLS.has(call.tool)) {
108 next = { ...next, searchCalls: next.searchCalls + 1 }
109 } else if (call.tool === 'Bash' && call.isReadOnly && isSearchCommand(call.command ?? '')) {
110 next = { ...next, searchCalls: next.searchCalls + 1 }
111 } else if (EDIT_TOOLS.has(call.tool)) {
112 next = { ...next, filesEdited: withPath(next.filesEdited, call.filePath) }
113 }
114
115 if (call.isReadOnly || READ_ONLY_BY_NATURE.has(call.tool)) {
116 next = { ...next, outputChars: next.outputChars + call.outputChars }
117 }
118 }
119
120 for (const kind of KINDS) {
121 if (next.firedKinds.includes(kind) || !crossed(kind, next, thresholds)) continue
122 next = { ...next, firedKinds: [...next.firedKinds, kind], nudged: true }
123 reminders.push(reminderFor(kind, next))
124 }
125
126 return { state: next, reminders }
127}
128
129export function formatStatus(state: TurnState): string {
130 const out = state.outputChars >= 1000 ? thousands(state.outputChars) : String(state.outputChars)
131 return `dg ${out}·${state.filesRead.length}f·${state.searchCalls}s·${state.delegations}↗`
132}
133
134export function emptySession(identity: SessionIdentity): SessionRecord {
135 return {
136 id: identity.id,
137 startedAt: identity.startedAt,
138 cwd: identity.cwd,
139 turns: 0,
140 nudgesByKind: { volume: 0, 'breadth-files': 0, 'breadth-search': 0, edits: 0 },
141 nudgedTurns: 0,
142 followedTurns: 0,
143 delegations: 0,
144 peakOutputChars: 0,
145 }
146}
147
148export function foldTurn(session: SessionRecord, turn: TurnState): SessionRecord {
149 const nudgesByKind = { ...session.nudgesByKind }
150 for (const kind of turn.firedKinds) nudgesByKind[kind] += 1
151 return {
152 ...session,
153 turns: session.turns + 1,
154 nudgesByKind,
155 nudgedTurns: session.nudgedTurns + (turn.nudged ? 1 : 0),
156 followedTurns: session.followedTurns + (turn.nudged && turn.followed ? 1 : 0),
157 delegations: session.delegations + turn.delegations,
158 peakOutputChars: Math.max(session.peakOutputChars, turn.outputChars),
159 }
160}
161
162export function upsertSession(
163 sessions: readonly SessionRecord[],
164 identity: SessionIdentity,
165 turn: TurnState,
166 max: number = MAX_SESSIONS,
167): SessionRecord[] {
168 const existing = sessions.find(s => s.id === identity.id)
169 const updated = foldTurn(existing ?? emptySession(identity), turn)
170 const rest = sessions.filter(s => s.id !== identity.id)
171 return [...rest, updated].slice(-max)
172}
173
174export function totalNudges(session: SessionRecord): number {
175 return KINDS.reduce((sum, kind) => sum + session.nudgesByKind[kind], 0)
176}
177
178export type StatsTotals = {
179 turns: number
180 nudges: number
181 nudgedTurns: number
182 followedTurns: number
183 delegations: number
184 followRate: number | null
185 nudgesPerTurn: number | null
186}
187
188export function totalsOf(sessions: readonly SessionRecord[]): StatsTotals {
189 const turns = sessions.reduce((sum, s) => sum + s.turns, 0)
190 const nudges = sessions.reduce((sum, s) => sum + totalNudges(s), 0)
191 const nudgedTurns = sessions.reduce((sum, s) => sum + s.nudgedTurns, 0)
192 const followedTurns = sessions.reduce((sum, s) => sum + s.followedTurns, 0)
193 const delegations = sessions.reduce((sum, s) => sum + s.delegations, 0)
194 return {
195 turns,
196 nudges,
197 nudgedTurns,
198 followedTurns,
199 delegations,
200 followRate: nudgedTurns === 0 ? null : followedTurns / nudgedTurns,
201 nudgesPerTurn: turns === 0 ? null : nudges / turns,
202 }
203}
204
205function sessionLabel(session: SessionRecord): string {
206 const when = new Date(session.startedAt).toISOString().slice(0, 16).replace('T', ' ')
207 const dir = session.cwd ? (session.cwd.split('/').filter(Boolean).pop() ?? '') : ''
208 return dir ? `${when} ${dir}` : when
209}
210
211export function formatStats(sessions: readonly SessionRecord[], count: number): string {
212 if (sessions.length === 0) return 'delegate-guard: no sessions recorded yet.'
213 const shown = sessions.slice(-Math.max(1, count))
214 const header = ['session', 'turns', 'nudges', 'nudged', 'followed', 'deleg', 'peak']
215 const rows = shown.map(s => [
216 sessionLabel(s),
217 String(s.turns),
218 String(totalNudges(s)),
219 String(s.nudgedTurns),
220 String(s.followedTurns),
221 String(s.delegations),
222 thousands(s.peakOutputChars),
223 ])
224 const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? '').length)))
225 const line = (cells: readonly string[]): string =>
226 cells.map((c, i) => (i === 0 ? c.padEnd(widths[i] ?? 0) : c.padStart(widths[i] ?? 0))).join(' ')
227
228 const totals = totalsOf(shown)
229 const rate = totals.followRate === null ? 'n/a' : `${Math.round(totals.followRate * 100)}% (${totals.followedTurns}/${totals.nudgedTurns})`
230 const perTurn = totals.nudgesPerTurn === null ? 'n/a' : totals.nudgesPerTurn.toFixed(2)
231
232 return [
233 `delegate-guard: last ${shown.length} of ${sessions.length} sessions`,
234 line(header),
235 ...rows.map(line),
236 '',
237 `totals (shown): ${totals.turns} turns, ${totals.nudges} nudges, ${totals.delegations} delegations`,
238 `nudge-follow rate: ${rate}`,
239 `nudges per turn: ${perTurn}`,
240 ].join('\n')
241}
242types/index.d.ts 41 lines1export type FiredKind = 'volume' | 'breadth-files' | 'breadth-search' | 'edits'
2
3export type TurnState = {
4 outputChars: number
5 filesRead: string[]
6 searchCalls: number
7 filesEdited: string[]
8 firedKinds: FiredKind[]
9 nudged: boolean
10 delegations: number
11 followed: boolean
12}
13
14export type SessionRecord = {
15 id: string
16 startedAt: number
17 cwd: string | null
18 turns: number
19 nudgesByKind: Record<FiredKind, number>
20 nudgedTurns: number
21 followedTurns: number
22 delegations: number
23 peakOutputChars: number
24}
25
26export type SessionIdentity = {
27 id: string
28 startedAt: number
29 cwd: string | null
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'delegate-guard': {
35 turn: TurnState
36 isEnabled: boolean | null
37 session: SessionIdentity | null
38 }
39 }
40}
41