diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a89df..597b2de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ a percentage from 0 to 1,000, or it is left out and the record's counts still count. Journal rows read back for `/agent-guard report`, which Claude also reads, must hold only the values the guard writes. +- **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.3 — 2026-10-31 diff --git a/README.md b/README.md index 2636ae1..84b1880 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,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. - It never submits a prompt or writes in the input box. After "Compact first", the prompt you typed waits there for you to send. - 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). Each record is stored under the session's full id, which is also the name of that session's transcript file. +- 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). Each record is stored under the session's full id, which is also the name of that session's transcript file. 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 2ba434d..01b9649 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 first, or cancels, and the prompt waits in the input box either way, never sent by the guard; a compaction that fails or a hook vetoes says so; 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, from rows that hold only the values the guard writes; 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 79240da..296cfb2 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -101,6 +101,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 @@ -423,7 +425,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'}` })) } @@ -525,7 +527,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' }) } @@ -542,13 +544,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 { @@ -588,7 +607,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) @@ -754,7 +774,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 d695a81..041af83 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 b2b485f..7aa0288 100644 --- a/tests/harness.ts +++ b/tests/harness.ts @@ -44,6 +44,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 { @@ -67,10 +69,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 } })