From a86b95e806eedd1d09733b14d9e874bf349a8e22 Mon Sep 17 00:00:00 2001 From: Riccardo Bartoli Date: Sat, 24 Oct 2026 03:26:18 +0100 Subject: [PATCH] fix: say when the store stops saving --- CHANGELOG.md | 5 +++++ README.md | 2 +- TESTING.md | 2 +- core/status.ts | 4 ++++ hooks/register.ts | 30 +++++++++++++++++++++++++----- tests/agents.test.ts | 12 ++++++++++++ tests/harness.ts | 4 ++++ 7 files changed, 52 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f0ec6..5d755fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 528bf01..e176b9d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TESTING.md b/TESTING.md index 42d3a37..8d0615b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/core/status.ts b/core/status.ts index 3a7d7a4..e81d998 100644 --- a/core/status.ts +++ b/core/status.ts @@ -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.` @@ -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') } diff --git a/hooks/register.ts b/hooks/register.ts index 49b3784..1c17a65 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -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 @@ -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'}` })) } @@ -543,7 +545,7 @@ async function publish($: Api, now: number): Promise { 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' }) } @@ -560,13 +562,30 @@ async function keepLastActive($: Api): Promise { 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 { + 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 { try { @@ -606,7 +625,8 @@ async function refreshStatus($: Api): Promise { 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) @@ -767,7 +787,7 @@ async function journal($: Api, now: number, row: Omit): P const clean = Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined)) as Omit 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' }) } diff --git a/tests/agents.test.ts b/tests/agents.test.ts index ebe3327..d3a32e2 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -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', () => { diff --git a/tests/harness.ts b/tests/harness.ts index 0ffc753..2d16a37 100644 --- a/tests/harness.ts +++ b/tests/harness.ts @@ -45,6 +45,8 @@ export type World = { setContext(tokens: number | undefined): void /** The usage the next model request reports. */ nextUsage: Record | undefined + /** While true, the store refuses every write, as when it is full. */ + storeFull: boolean } export function world(on: On, options: WorldOptions = {}): World { @@ -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 } })