Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
judge for any reason is now refused, so Claude starts it again; before,
only an overrun was. Tool calls and prompts still go ahead when the guard
fails.
- **`/agent-guard simulate <rule>` shows what a rule does.** It builds a
made-up state that trips the rule and runs the real gate on it. Where
someone can answer, the real question opens in Claude Code's dialog, marked
as a simulation; elsewhere it prints the refusal Claude would read or the
reason a prompt would be held. Nothing is held, nothing in the session
changes, the journal records nothing, and no usage is spent. The rules are
`concurrency`, `starts`, `agent-tokens`, `burn`, `limit`, `limit-deny`,
`depth`, `retry`, `tool-budget`, `prompt-context` and `dormant`. The
README's install steps now end with one, so a new user sees the guard ask
within a minute.

## 1.1.1 — 2026-10-23

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Run these commands inside Claude Code v2.1.287 or later:
/reload-plugins
```

Then run `/agent-guard` to see what it sees. Mods are on by default; the [overview](https://code.claude.com/docs/en/plugins/mods/overview#turn-mods-on-or-off) lists the settings that turn them off.
Then run `/agent-guard` to see what it sees, and `/agent-guard simulate concurrency` to see the question it asks when too many agents run, with nothing held or spent. Mods are on by default; the [overview](https://code.claude.com/docs/en/plugins/mods/overview#turn-mods-on-or-off) lists the settings that turn them off.

## Why this exists

Expand Down Expand Up @@ -85,6 +85,7 @@ Agents covers subagents, agent-team teammates, workflow agents, and finished sub
/agent-guard pause [minutes] the same as allow, for every limit
/agent-guard resume end an allow early, lift the agent pause, and let declined questions ask again
/agent-guard report [days] what it did on this machine (default: 7 days)
/agent-guard simulate <rule> show what a rule does, with nothing held or spent
/agent-guard help these commands
```

Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ npx -p typescript@5.9 tsc -p tsconfig.json
| Tool gate | Retry fuse on identical failures in a row only, so test, edit, re-run is not a loop; a permission refusal is not a failure; the heavy-context budget asks once, stops the turn, or compacts after it; subagents judged on their own context; `SubagentHandback` and `TaskStop` never held; compaction, or a context reading below the threshold, clears the budget |
| Prompt gate | Heavy prompt asks, compacts then resends as the user's own words, or cancels back into the input box; a compaction that fails or a hook vetoes puts the prompt back instead; headless holds with a reason; notifications pass untouched; dormant resumes ask or are capped across sessions; the context warning is given once, on a prompt the user typed; from 95% of a plan window, read in any session, each session is told once to wind down, and again only in a new window; a session compacted while idle is not asked |
| Denials | Each refusal of a condition reads differently and escalates; separate conditions count separately; the fuse pauses spawns and clears, and `resume` lifts it; a Stop hook reopening the turn changes the wording |
| Overrides | `/agent-guard allow`, `resume`, the old markers sent alone, and the off switch; `allow` and `pause` refused when another plugin runs them |
| Overrides | `simulate` trips every rule with the defaults, opens the real question or prints the refusal, and changes nothing; `/agent-guard allow`, `resume`, the old markers sent alone, and the off switch; `allow` and `pause` refused when another plugin runs them |
| Status line | Redrawn when a plan window it shows resets or an allow ends, in a session where nothing else happens |
| Journal | Lockouts classified (usage, weekly, spend, one model) and transient rate limits ignored; each locked window counted once, across sessions and retries; report across sessions; retention and stale-record cleanup; rows hold no prompt text |

Expand Down
8 changes: 8 additions & 0 deletions core/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// runs, so an override is always the user's. The model does read the
// command's reply with its next request.

import { SIMULATED, type SimulatedRule } from './simulate.ts'
import { MAIN, type Scope, type SessionState, loopOf } from './state.ts'
import { duration, sentence, shortClock } from './text.ts'
import { USAGE, scopeName } from './status.ts'
Expand All @@ -12,6 +13,7 @@ export type Command =
| { action: 'allow'; scope: Scope; minutes: number }
| { action: 'resume' }
| { action: 'report'; days: number }
| { action: 'simulate'; rule: SimulatedRule }
| { action: 'help'; error?: string }

export const DEFAULT_ALLOW_MINUTES = 10
Expand All @@ -30,6 +32,12 @@ export function parseCommand(args: string): Command {
if (!Number.isInteger(days) || days < 1 || days > MAX_REPORT_DAYS) return { action: 'help', error: `"${rest[0]}" is not a number of days` }
return { action: 'report', days }
}
if (verb === 'simulate') {
const rule = SIMULATED.find((r) => r === rest[0])
if (rule) return { action: 'simulate', rule }
const rules = SIMULATED.join(', ')
return { action: 'help', error: rest[0] === undefined ? `name a rule to simulate: ${rules}` : `"${rest[0]}" is not a rule; the rules are ${rules}` }
}
if (verb === 'allow' || verb === 'pause') {
let scope: Scope = 'all'
let minutes = DEFAULT_ALLOW_MINUTES
Expand Down
1 change: 1 addition & 0 deletions core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export {
peerRecord,
prune,
} from './state.ts'
export { SIMULATED, type SimulatedRule, simulate } from './simulate.ts'
export { USAGE, statusLine, statusLineExpiry, statusReport } from './status.ts'
export { canonical, contextBucket, fingerprint } from './text.ts'
export { type HeavyOutcome, type ToolRequest, resolveHeavyAsk, toolGate } from './tools.ts'
Expand Down
77 changes: 77 additions & 0 deletions core/simulate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// `/agent-guard simulate <rule>`: what a rule does, on made-up state, with the
// real gates. Nothing it builds touches the session's own state.

import { type AgentRequest, agentGate } from './agents.ts'
import type { Config } from './config.ts'
import { promptGate } from './prompts.ts'
import { type LimitReading, MAIN, loopOf, newSession } from './state.ts'
import { toolGate } from './tools.ts'
import type { Verdict } from './verdict.ts'

export const SIMULATED = [
'concurrency',
'starts',
'agent-tokens',
'burn',
'limit',
'limit-deny',
'depth',
'retry',
'tool-budget',
'prompt-context',
'dormant',
] as const

export type SimulatedRule = (typeof SIMULATED)[number]

const MINUTE = 60_000
const HOUR = 60 * MINUTE

/** The verdict `rule` gives on state built to trip it, as a session where `canAsk` holds would see it. */
export function simulate(rule: SimulatedRule, config: Config, now: number, canAsk: boolean): Verdict {
const state = newSession('simulation', canAsk)
const agent: AgentRequest = { loop: MAIN, depth: 1, resume: false }
const fiveHour = (percent: number, at = now): LimitReading => ({ at, kind: 'five_hour', percent, resetsAt: now + 3 * HOUR })
const main = loopOf(state, MAIN)
switch (rule) {
case 'concurrency':
for (let i = 0; i < config.agentMax; i++) {
state.agents[`simulated-${i}`] = { id: `simulated-${i}`, kind: 'subagent', depth: 1, running: true, startedAt: now }
}
return agentGate(state, [], config, now, agent)
case 'starts':
state.starts = Array.from({ length: config.rollingMax }, () => now - MINUTE)
return agentGate(state, [], config, now, agent)
case 'agent-tokens':
state.agentTokens = [[now - MINUTE, config.agentTokensMax]]
return agentGate(state, [], config, now, agent)
case 'burn': {
const low = fiveHour(30, now - 5 * MINUTE)
state.limits.five_hour = fiveHour(30 + config.burnPercent)
state.limitHistory = [low, state.limits.five_hour]
return agentGate(state, [], config, now, agent)
}
case 'limit':
state.limits.five_hour = fiveHour(config.limitAskPercent)
return agentGate(state, [], config, now, agent)
case 'limit-deny':
state.limits.five_hour = fiveHour(config.limitDenyPercent)
return agentGate(state, [], config, now, agent)
case 'depth':
return agentGate(state, [], config, now, { ...agent, depth: config.depthMax + 1 })
case 'retry':
state.failures.simulated = { count: config.failureMax, last: now - MINUTE, loop: MAIN }
return toolGate(state, config, now, { loop: MAIN, tool: 'Bash', fingerprint: 'simulated', label: 'this conversation' })
case 'tool-budget':
main.context = config.toolContext + 50_000
main.heavyCalls = Array.from({ length: config.toolMax }, () => now - MINUTE)
return toolGate(state, config, now, { loop: MAIN, tool: 'Read', fingerprint: 'simulated', label: 'this conversation' })
case 'prompt-context':
main.context = config.contextHard + 20_000
return promptGate(state, [], config, now, { source: 'user', text: '' })
case 'dormant':
main.context = Math.min(config.dormantContext + 10_000, config.contextHard - 1)
state.dormantResumes = Array.from({ length: config.dormantMax }, () => now - MINUTE)
return promptGate(state, [], config, now, { source: 'user', text: '', idleMs: config.dormantMs + HOUR })
}
}
1 change: 1 addition & 0 deletions core/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,6 @@ export const USAGE = [
' /agent-guard pause [minutes] same as allow, for every limit',
' /agent-guard resume end an allow or pause early, and clear declined questions',
' /agent-guard report [days] what the guard did on this machine (default 7 days)',
' /agent-guard simulate <rule> show what a rule does, with nothing held or spent',
' /agent-guard status this summary',
].join('\n')
28 changes: 28 additions & 0 deletions hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type PromptSource,
SIGNATURE,
type SessionState,
type SimulatedRule,
type ToolRequest,
type Verdict,
agentGate,
Expand Down Expand Up @@ -59,6 +60,7 @@ import {
resolveHeavyAsk,
resolvePromptAsk,
resumeGuard,
simulate,
splitJournalKeys,
statusLine,
statusLineExpiry,
Expand Down Expand Up @@ -435,6 +437,7 @@ export function register(on: On): void {
await refreshStatus($)
return { text }
}
if (command.action === 'simulate') return { text: await simulation($, command.rule, now) }
if (command.action === 'resume') {
const text = resumeGuard(session)
await refreshStatus($)
Expand Down Expand Up @@ -683,6 +686,31 @@ async function oneAtATime<T>(key: string, held: () => T, run: () => Promise<T>):
}
}

/**
* Shows what a rule does on made-up state: the real question in the dialog
* where someone can answer, else the refusal or the held prompt's reason. The
* reply opens by saying it is a simulation, because Claude reads it too.
*/
async function simulation($: Api, rule: SimulatedRule, now: number): Promise<string> {
const verdict = simulate(rule, config, now, canAsk())
const opening = `Simulation of ${rule}`
const aside = `${opening}: nothing was held, and none of this applies to the current work.`
if (verdict.kind === 'ask') {
if (canAsk()) {
try {
const answer = await $.ui.ask(`Simulation, nothing changes: ${verdict.question}`, { options: verdict.options, header: HEADER })
return `${opening}: you answered "${answer}". Nothing was held, nothing changed and no usage was spent.`
} catch {
// Dismissed, or the dialog could not open: show the question instead.
}
}
return `${aside} The guard would ask:\n\n${verdict.question}\nOptions: ${verdict.options.join(' · ')}`
}
if (verdict.kind === 'deny') return `${aside} Claude would read this refusal:\n\n${verdict.message}`
if (verdict.kind === 'drop') return `${aside} A prompt you send would be held:\n\n${verdict.message}`
return `${opening}: with your settings, this rule would not trip.`
}

/** Compacts the conversation. Returns why it did not, or undefined once it has. */
async function compact($: Api): Promise<string | undefined> {
try {
Expand Down
23 changes: 23 additions & 0 deletions tests/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,29 @@ describe('agent gate', () => {
})
})

describe('simulate', () => {
test('a simulated question opens the real dialog, and nothing changes whatever the answer', async ($, on) => {
const w = world(on, { answers: ["Don't start it"] })
await start($, w, true)
const result = await $.command.run({ command: 'agent-guard', args: 'simulate concurrency', origin: TYPED } as never)
expect(w.questions[0]).toMatch(/^Simulation, nothing changes: 4 agents are running on this machine \(limit 4\)/)
expect(result.text).toBe('Simulation of concurrency: you answered "Don\'t start it". Nothing was held, nothing changed and no usage was spent.')
expect((await spawn($, w)).agentId).toBeDefined()
expect(journalRows(w)).toEqual([])
})

test('where nobody can answer, it prints the refusal Claude would read or the reason a prompt would be held', async ($, on) => {
const w = world(on)
await start($, w, false)
const retry = await $.command.run({ command: 'agent-guard', args: 'simulate retry', origin: TYPED } as never)
expect(retry.text).toMatch(/^Simulation of retry: nothing was held, and none of this applies to the current work\. Claude would read this refusal:\n\nThis exact Bash call failed 3 times in a row/)
const prompt = await $.command.run({ command: 'agent-guard', args: 'simulate prompt-context', origin: TYPED } as never)
expect(prompt.text).toMatch(/A prompt you send would be held:\n\nagent-usage-guard: this session holds 520k context tokens/)
expect(w.questions).toEqual([])
expect(journalRows(w)).toEqual([])
})
})

describe('plan-window gate', () => {
const measure = (percent: number, resetsAt = '2027-01-15T15:00:00.000Z', kind = 'five_hour') => ({
context: { window: 1_000_000 },
Expand Down
17 changes: 17 additions & 0 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
parseConfig,
peerRecord,
prune,
SIMULATED,
simulate,
splitJournalKeys,
} from '../core/index.ts'

Expand Down Expand Up @@ -57,6 +59,7 @@ describe('commands', () => {
expect(parseCommand('pause 20')).toEqual({ action: 'allow', scope: 'all', minutes: 20 })
expect(parseCommand('report 30')).toEqual({ action: 'report', days: 30 })
expect(parseCommand('resume')).toEqual({ action: 'resume' })
expect(parseCommand('simulate Concurrency')).toEqual({ action: 'simulate', rule: 'concurrency' })
})

test('rejects what it cannot read instead of guessing', () => {
Expand All @@ -65,6 +68,20 @@ describe('commands', () => {
expect(parseCommand('allow 9999')).toMatchObject({ action: 'help' })
expect(parseCommand('report -1')).toMatchObject({ action: 'help' })
expect(parseCommand('frobnicate')).toEqual({ action: 'help', error: 'unknown subcommand "frobnicate"' })
expect(parseCommand('simulate')).toMatchObject({ action: 'help', error: expect.stringMatching(/^name a rule to simulate: concurrency, starts, /) })
expect(parseCommand('simulate everything')).toMatchObject({ action: 'help', error: expect.stringMatching(/^"everything" is not a rule/) })
})
})

describe('simulate', () => {
test('every rule trips with the default settings, asking where someone can answer and refusing or holding where nobody can', () => {
for (const rule of SIMULATED) {
for (const canAsk of [true, false]) {
const verdict = simulate(rule, defaultConfig(), NOW, canAsk)
expect([rule, verdict.kind]).not.toEqual([rule, 'allow'])
if (!canAsk) expect([rule, verdict.kind]).not.toEqual([rule, 'ask'])
}
}
})
})

Expand Down
Loading