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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
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.
- **The guard says when its store stops saving.** The mod store holds 4 MiB
in all, and a write that failed was logged only to the debug log. Other
sessions then stopped counting this session's agents, with no sign. Now the
status line reads "store not saving: other sessions can't count this one",
and `/agent-guard status` gives the error, until a write works again.

## 1.1.1 — 2026-10-23

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ A mod runs inside Claude Code with your permissions; read [what a mod can reach]
- It decides only what the rules above describe: whether an agent starts, a prompt is sent, or a tool call runs. It asks you whenever you can answer, and gives the reason when it refuses. It reads permission modes but never changes them, and it writes no settings.
- The only prompt it submits is your own: after "Compact, then send", it sends the prompt you typed, unchanged, once compaction finishes.
- Its prompt hook passes your prompt on unchanged, adds a one-time note for Claude when the context passes 300k or a plan window reaches 95%, or holds the prompt to ask you first (when nobody can answer, it says why instead). Its `SessionStart` hook changes nothing.
- It keeps its state in the mod store, a JSON file under `~/.claude/plugins/store/`. Each session writes its own counts (running agents, recent starts and dormant resumes, subagent tokens per minute, its latest 5-hour reading), when it was last active (kept 30 days, so a resumed session knows how long it sat idle), and its journal rows (time, a session-id prefix, rule, tool name, context bucket such as `400-500k`, plan window and percentage, refusal number, and your answer).
- It keeps its state in the mod store, a JSON file under `~/.claude/plugins/store/`. Each session writes its own counts (running agents, recent starts and dormant resumes, subagent tokens per minute, its latest 5-hour reading), when it was last active (kept 30 days, so a resumed session knows how long it sat idle), and its journal rows (time, a session-id prefix, rule, tool name, context bucket such as `400-500k`, plan window and percentage, refusal number, and your answer). If the store stops saving, such as when it is full, the status line says so: other sessions then stop counting this one.
- It never stores prompt text, tool inputs, error text or model output. A tool call is reduced to a 13-character fingerprint. Agent names stay in memory and are never written.
- Journal days older than 90 days are deleted (`AGENT_GUARD_JOURNAL_DAYS`).
- `demo/`, which builds the GIF above, is not part of what runs. It starts Claude Code in a temporary home directory with a made-up API key for a scripted local API, and reads none of your credentials.
Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ npx -p typescript@5.9 tsc -p tsconfig.json
| 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 |
| Status line | Redrawn when a plan window it shows resets or an allow ends, in a session where nothing else happens |
| Status line | Redrawn when a plan window it shows resets or an allow ends, in a session where nothing else happens; says when the store stops saving, and clears once a save works |
| 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 |

## What is checked by hand
Expand Down
4 changes: 4 additions & 0 deletions core/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function statusReport(
now: number,
problems: readonly string[],
version?: string,
storeFailure?: string,
): string {
const running = version ? ` (${version})` : ''
if (!config.enabled) return `Off${running}: AGENT_GUARD is set to turn it off.`
Expand Down Expand Up @@ -93,6 +94,9 @@ export function statusReport(
for (const [key, until] of Object.entries(state.refusals)) if (now < until) held.push(`${key} declined until ${shortClock(until)}`)
if (held.length > 0) lines.push(`Now: ${held.join('; ')}.`)
for (const problem of problems) lines.push(`Config: ${problem}.`)
if (storeFailure !== undefined) {
lines.push(`Store: the guard could not save its counts (${storeFailure}), so other sessions on this machine don't count this session's agents. The journal may miss rows too.`)
}
lines.push('', USAGE)
return lines.join('\n')
}
Expand Down
30 changes: 25 additions & 5 deletions hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ let lastPublished = 0
/** The last-active time this process last stored, so a heartbeat does not rewrite it. */
let lastActiveKept: number | undefined
let lastStatus: string | undefined
/** Why the last write to the store failed, while no write has worked since. */
let storeFailure: string | undefined
/** The pending redraw of the status line, and the time it is for. */
let redraw: { at: number; timer: Timer } | undefined
let compactAfterTurn = false
Expand Down Expand Up @@ -441,7 +443,7 @@ export function register(on: On): void {
return { text }
}
await loadPeers($, now)
return { text: statusReport(session, peers, config, now, problems, await pluginVersion($)) }
return { text: statusReport(session, peers, config, now, problems, await pluginVersion($), storeFailure) }
}).catch(($, e, next) => ({ text: `The command failed: ${next.error?.message ?? 'unknown error'}` }))
}

Expand Down Expand Up @@ -543,7 +545,7 @@ async function publish($: Api, now: number): Promise<void> {
expireAgents(session, now, config.peerTtlMs)
prune(session, now, config.windowMs)
try {
await $.store.set(PEER_PREFIX + session.id, peerRecord(session, now, config.windowMs))
await save($, PEER_PREFIX + session.id, peerRecord(session, now, config.windowMs))
} catch (error) {
$.ui.log(`agent-usage-guard could not publish its counts: ${String(error)}`, { to: 'debug' })
}
Expand All @@ -560,13 +562,30 @@ async function keepLastActive($: Api): Promise<void> {
const last = session.loops[MAIN]?.activeAt
if (!session.id || last === undefined || last === lastActiveKept) return
try {
await $.store.set(LAST_ACTIVE_PREFIX + session.id, last)
await save($, LAST_ACTIVE_PREFIX + session.id, last)
lastActiveKept = last
} catch (error) {
$.ui.log(`agent-usage-guard could not store when the session was last active: ${String(error)}`, { to: 'debug' })
}
}

/**
* Writes to the store and keeps track of whether writes work: when they stop,
* other sessions no longer see this one's counts, and the status says so.
*/
async function save($: Api, key: string, value: unknown): Promise<void> {
const before = storeFailure
try {
await $.store.set(key, value)
storeFailure = undefined
} catch (error) {
storeFailure = error instanceof Error ? error.message : String(error)
throw error
} finally {
if ((before === undefined) !== (storeFailure === undefined)) await refreshStatus($)
}
}

/** Reads the other sessions' records; a failure leaves the last ones read. */
async function loadPeers($: Api, now: number): Promise<void> {
try {
Expand Down Expand Up @@ -606,7 +625,8 @@ async function refreshStatus($: Api): Promise<void> {
try {
const now = await $.clock.now()
redrawAt($, statusLineExpiry(session, peers, config, now), now)
const text = statusLine(session, peers, config, now)
const parts = [statusLine(session, peers, config, now), storeFailure === undefined || !config.enabled ? undefined : "store not saving: other sessions can't count this one"]
const text = parts.filter((p) => p !== undefined).join(' · ') || undefined
if (text === lastStatus) return
lastStatus = text
$.ui.status(text)
Expand Down Expand Up @@ -767,7 +787,7 @@ async function journal($: Api, now: number, row: Omit<JournalRow, 't' | 's'>): P
const clean = Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined)) as Omit<JournalRow, 't' | 's'>
journalCache.rows.push({ t: now, s: session.id.slice(0, 8), ...clean })
if (journalCache.rows.length > MAX_ROWS_PER_DAY) journalCache.rows.splice(0, journalCache.rows.length - MAX_ROWS_PER_DAY)
await $.store.set(key, journalCache.rows)
await save($, key, journalCache.rows)
} catch (error) {
$.ui.log(`agent-usage-guard could not write its journal: ${String(error)}`, { to: 'debug' })
}
Expand Down
12 changes: 12 additions & 0 deletions tests/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ describe('agent gate', () => {
await spawn($, w)
expect(w.questions[0]).toMatch(/subagents processed 1\.2M tokens in the last 10 min \(limit 1M\)/i)
})
test('a store that stops saving shows in the status line and the status, until a save works again', async ($, on) => {
const w = world(on)
await start($, w, true)
w.storeFull = true
await spawn($, w)
expect(w.statuses.at(-1)).toBe("store not saving: other sessions can't count this one")
const status = await $.command.run({ command: 'agent-guard', args: 'status', origin: TYPED } as never)
expect(status.text).toMatch(/^Store: the guard could not save its counts \(.+\), so other sessions on this machine don't count this session's agents\./m)
w.storeFull = false
await spawn($, w)
expect(w.statuses.at(-1)).toBeUndefined()
})
})

describe('plan-window gate', () => {
Expand Down
4 changes: 4 additions & 0 deletions tests/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type World = {
setContext(tokens: number | undefined): void
/** The usage the next model request reports. */
nextUsage: Record<string, number> | undefined
/** While true, the store refuses every write, as when it is full. */
storeFull: boolean
}

export function world(on: On, options: WorldOptions = {}): World {
Expand All @@ -69,10 +71,12 @@ export function world(on: On, options: WorldOptions = {}): World {
contextTokens = tokens
},
nextUsage: undefined,
storeFull: false,
}

on('store.get', ($, e) => ({ value: structuredClone(store.get(e.key)) }))
on('store.set', ($, e) => {
if (w.storeFull) throw new Error('the store would pass 4 MiB')
store.set(e.key, structuredClone(e.value))
return { value: undefined }
})
Expand Down
Loading