Warns before a cold prompt cache re-bills the whole context, and shows the cache and subagent state on the status line

Single-command bootstrap for AI coding tools. One repo, one install, full environment reproducible on any linux machine in under 30 seconds.

| Metric | Count |
|---|---|
| Config templates | 80 |
| Managed files deployed | 52 |
| Hooks (Claude Code) | 35 |
| Hooks (Cursor events) | 9 |
| Skills (shared) | 19 |
| Plugins (Claude Code) | 10 |
| MCP servers | 5 |
| Modules | 9 |
| Template variables | 5 |
| No-op redeploy | <2s |
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash
First run clones the repo to ~/.harness, detects installed tools, and deploys everything. Subsequent runs are idempotent (checksum-based skip).
For a machine that should get the hooks, skills, commands and rules but neither second-brain nor the wiki (an interview laptop, say), pass --no-memory. Through the curl bootstrap the flag goes after bash -s and the double-hyphen separator:
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash -s -- --no-memory
HARNESS_NO_MEMORY=1 does the same and also survives the bootstrap re-exec. A lite install skips the second-brain and wiki modules (no cargo build, daemon, sync timer or MCP registration), the memory hooks, the two memory rules, the wiki skill, the devlog, plan and decision commands and the WIKI_VAULT env entry. --only second-brain and --only wiki cannot be combined with it.
Lite mode is for machines that never had the full install. Switching from full to lite does not undo the earlier install: the second-brain systemd unit and sync timer, the claude mcp add entry in ~/.claude.json, and the MCP entries in the Codex, Cursor and opencode configs stay until removed by hand, because uninstall only restores files the manifest tracks. Switching from lite to full just works.
~/.claude/settings.json ← permissions, hooks config, enabledMcpjsonServers
~/.claude/.mcp.json ← MCP server definitions (cognitive-cache, web-strip, markitdown, second-brain)
~/.claude/CLAUDE.md ← global instructions (exploration-first, TDD, scope gates, no AI comments)
~/.claude/hooks/ ← 35 hook scripts (see below)
~/.claude/skills/ ← 19 skill definitions
~/.claude/plugins/ ← 10 plugins from 2 marketplaces (claude-plugins-official, harness-mods)
~/.claude/harness-mods/ ← local folder marketplace holding the harness's own mods
~/.local/bin/claude-profile ← one Claude Code profile per account (below)
claude-profile asks for a name and creates ~/.claude-<name> plus a claude-<name> launcher that runs Claude with CLAUDE_CONFIG_DIR pointed at it, then offers claude auth login. Each profile signs in to its own account and keeps its own .claude.json, while settings, hooks, CLAUDE.md, skills, commands, plugins, mods and projects/ (transcripts and auto-memory) are symlinks into ~/.claude, so every profile runs the same harness and can resume the others' sessions. User-scope MCP servers are merged in from ~/.claude.json.
claude-profile list shows each profile's account, claude-profile remove <name> deletes one, and claude-profile sync relinks all of them; every install runs sync, so profiles pick up new entries and MCP servers. An install started from inside a profile session still targets ~/.claude. If Claude replaces a shared symlink with its own file (a settings write, say), sync reports it instead of overwriting it.
~/.cursor/rules/*.mdc ← 15 shared rules (alwaysApply)
~/.cursor/mcp.json ← MCP servers (merged with existing; harness adds second-brain, cognitive-cache, web-strip, markitdown)
~/.cursor/hooks.json ← 9 hook events (session, prompt, tool, shell, edit, stop, compact)
~/.cursor/hooks/ ← shared hook scripts (same family as Claude Code)
~/.cursor/skills/ ← shared skills (converge, humanize, ownit, wiki, terminal-gif)
~/.cursor/commands/ ← shared slash commands (plan, scope, commit, decision, devlog)
~/.codex/config.toml ← model, approval mode
~/.codex/instructions.md ← shared instructions
~/.agents/skills/ ← shared skills (Codex reads user skills here)
~/.config/opencode/AGENTS.md ← shared global rules
~/.config/opencode/commands/ ← shared slash commands (plan, scope, commit, decision, devlog)
~/.agents/skills/ ← shared skills (opencode auto-scans this dir and ~/.claude/skills)
opencode.json(c) stays unmanaged (model/provider config is machine-specific); a reference template lives at configs/opencode/opencode.jsonc.tmpl.
Mods are Claude Code plugins with an in-process hooks module (CLI 2.1.287+). The harness keeps them in configs/claude-code/mods/, a folder marketplace named harness-mods. install.sh copies it to ~/.claude/harness-mods/ (tests excluded), registers and installs each mod, and settings.json.tmpl carries the matching extraKnownMarketplaces and enabledPlugins entries so a redeploy never drops them. Claude Code reads an installed mod straight from that folder, so after a redeploy /reload-plugins picks up the change.
| Mod | What it does |
|---|---|
nudge-band | Band above the prompt showing scope (.scope.md missing or stale), wiki (devlog and index not written this session) and untracked junk, with buttons that pre-fill /scope, /devlog or a cleanup prompt. Claude gets one reminder when a condition first holds; the wiki reminder repeats after 3 more edits or 10 more turns without a wiki write. Active humanize and ownit modes show in the footer beside Claude Code's own modes. |
cache-guard | Tracks when the main thread last got a response and whether each turn's first request found the prompt cache warm or cold, learning the cache lifetime from those hits and misses (60 minutes until it has seen one). The status line ends with cache 42m, or cache cold ≈ $3.04 in yellow once the next send would re-write the context. When an idle composer prompt would re-write at least gateUsd (default $1) of context, it asks whether to send anyway, compact first or keep the prompt as a draft, and a dismissed or unanswerable question sends. A cold turn that re-wrote 50k tokens or more raises a toast with its estimated cost at API prices. Subagent steps are tracked the same way against their own learnt TTL (agentTtlMinutes, default 5): the status line adds agents 3 (or agents 3 · 1 cold in yellow) while $.agent.list() shows live agents, a cold resumed agent raises its own toast, and the band above the prompt lists up to three agents that sit idle past the TTL with 50k tokens or more, or have run over an hour, each with a Dismiss button. Ended subagents leave the list within a minute, so their records age out after a day instead. |
guard-watch | Watches tool.check verdicts and counts every call a settings PreToolUse guard denied, naming the guard from its message ([hook:cost-guard], BLOCKED by pre-bash-guard, or the hook script's path). The status line shows 2 blocks, in yellow once a subagent was blocked, and a subagent's block raises a toast (cost-guard blocked Explore's Read: ...) because it never reaches the main transcript. It only observes: the verdict passes through unchanged. |
context-audit | /context-audit prints what /context does not: MCP tokens per server (loaded and deferred), skills grouped by plugin or source, memory files, agent definitions and slash commands, each sorted by tokens, then up to five trim candidates (an MCP server over 5k loaded tokens, a skill over 1k, a memory file over 3k) with their share of the window. It reads $.session.usage({ breakdown: 'summary' }) on demand and runs nothing otherwise. |
handoff | Answers main-thread compaction (auto, /compact, and a plugin's such as cache-guard's "Compact first") with one handoff message instead of the built-in summary: handoff-digest.sh supplies the facts (files touched, repo state, last plan, newest turns) and a tool-less $.model.fork writes goal, next step, decisions, ruled-out paths, open questions and the verify command. Each handoff is saved as ~/.claude/handoffs/<slug>/<session>.handoff-<n>.md. A failed fork keeps the built-in summary with the digest appended; a failed digest asks the built-in summarizer to cover the reasoning sections. /compact classic [text] gets the built-in summary, and subagent compactions are left alone. |
Develop a mod with claude --plugin-dir configs/claude-code/mods/<mod> (hot reload), and check it with claude plugin validate --strict <dir> and claude plugin test <dir>; tests/test_harness_mods.sh runs both for every mod. A mod must not register a Bash tool.call hook while anthropics/claude-code#92533 is open, because that breaks worktree-isolated subagents.
A mod cannot draw on the status line itself, so a mod with something to show there writes { "text", "tone", "at" } to ${XDG_STATE_HOME:-~/.local/state}/harness/status/<session id>/<mod>.json and removes it when the session ends. statusline.sh appends each segment in a fixed order, draws warn in yellow, and skips a file older than 10 minutes.
| Hook | Trigger | Purpose | |
|---|---|---|---|
pre-edit-comment-guard.py | PreToolUse (Write/Edit) | Blocks AI-generated narration comments | |
pre-edit-code-quality.sh | PreToolUse (Write/Edit) | Rejects shortcut-seeking language | |
stop-sloppiness-guard.sh | PostResponse | Detects lazy patterns, injects correction | |
cost-guard.sh | PreToolUse (Bash) | Blocks unbounded output in main context | |
context-cold-start-guard.sh | beforeSubmitPrompt / preCompact (Cursor) | Blocks cold re-bill on large context after model change, compaction, or cache idle | |
pre-bash-guard.sh | PreToolUse (Bash) | Validates commands before execution | |
format-on-save.sh | PostToolUse (Write/Edit) | Auto-formats written files | |
post-compact-context.sh | SessionStart compact (Claude Code, Codex) / preCompact + postToolUse (Cursor) | Re-injects the active .scope.md after compaction | |
compact-note.sh | Called by post-compact-context.sh and the opencode harness-compact-scope.js plugin | Renders .scope.md within the injection cap | |
session-start.sh | SessionStart | Activates modes, checks environment | |
session-end-ingest.sh | SessionEnd | Ingests session into second-brain (machine memory) | |
session-start-wiki.sh | SessionStart | Injects project wiki context + devlog | |
worktree-sweep.sh | SessionStart (Claude Code, async, logs to ~/.local/state/harness/worktree-sweep.log) | Removes idle worktrees whose commits landed on main, salvaging uncommitted files to .git/worktree-salvage/; Codex and Cursor have no async session start, so run it by hand | |
humanize-*.js | Various | Humanize mode activation, config | |
ownit-*.js | Various | Ownership mindset activation, config | |
attention-notify.sh | Notification, PreToolUse `AskUserQuestion\ | ExitPlanMode`, prompt, tool and session-end events (Claude Code); PermissionRequest, Stop and the same cancel events (Codex); stop, prompt, tool and session-end (Cursor) | Pushes to ntfy when a session waits on you or finishes (see below) |
attention-core.sh | Called by attention-notify.sh and the opencode harness-attention.js plugin | Arms, cancels and sends the ntfy push |
On Claude Code the scope, wiki and hygiene nudges (scope-drift-detector.sh, wiki-nudge.sh, cleanup-nudge.sh) are replaced by the nudge-band mod below; Cursor and Codex keep the shell scripts.
Guards match on the code a command runs, not on quoted text or heredoc bodies (shell-code.sh), and every block is appended to ~/.local/state/harness/hook-blocks.jsonl (log-block.sh; override with HARNESS_BLOCK_LOG). Two scripts keep false positives visible:
scripts/hook-blocks.sh [--days N] summarises recent blocks per hook.scripts/guard-replay.sh [--transcripts N] [--hook NAME] replays real tool calls from Claude Code transcripts through the repo's current guards and lists what each would block. Run it before shipping a guard change and read every sample.Set NTFY_URL (your ntfy server) and NTFY_TOPIC in the environment or ~/.harness.env, plus NTFY_TOKEN if the topic needs a bearer token. Nothing is sent until both the URL and topic are set, so there is no fallback to the public ntfy.sh. A session that blocks on you (a question, plan approval or permission prompt) pushes at priority 4 after NTFY_BLOCK_DELAY seconds (default 15), and a finished turn pushes at priority 3 after NTFY_DONE_DELAY (default 60; Claude Code's own idle notice already waits a minute, so it adds nothing there). Answering, pressing Esc or typing a prompt inside that window cancels the push, and a session that has exited stays quiet.
Headless claude -p never pushes; set HARNESS_NOTIFY=0 for codex exec, opencode run or other scripted runs. Cursor only gets finished turns because it has no hook for approvals or agent questions. Codex asks you to trust new hook entries through /hooks after a redeploy. Every arm, cancel, send and skip is logged with its reason to ~/.local/state/harness/attention/log (last 200 lines).
| Server | Transport | Purpose |
|---|---|---|
second-brain | stdio | Personal knowledge graph (KùzuDB, BGE embeddings, 384-dim) |
cognitive-cache | stdio | Optimal file selection for LLM context |
web-strip | stdio | Clean markdown extraction from URLs (Readability) |
markitdown | stdio | PDF/DOCX/XLSX → markdown |
verdant | stdio | Cross-session tool call caching (per-project) |
| Category | Skills |
|---|---|
| SEO | seo, seo-audit, seo-page, seo-plan, seo-technical, seo-schema, seo-sitemap, seo-content, seo-images, seo-hreflang, seo-geo, seo-competitor-pages, seo-programmatic |
| Knowledge | wiki (Karpathy-pattern LLM wiki) |
| Workflow | terminal-gif, humanize, ownit |
claude Claude Code: configs, plugins, hooks, skills, MCP
cursor Cursor: rules, mcp.json, hooks.json, skills, commands
codex Codex: config.toml, instructions
opencode opencode: AGENTS.md, commands, skills via ~/.agents/skills
second-brain second-brain daemon + MCP server registration
wiki Obsidian wiki vault init (Karpathy pattern, per-project)
watch File watcher for auto-ingest
leakguard gitleaks + global pre-commit scan for personal info/secrets;
pairs with the publish-leak-guard Claude hook that scans built
artifacts (sdists/wheels) before uv publish/twine upload.
Escape hatches: '#gitleaks:allow' inline, LEAKGUARD_SKIP=1.
Caveat: repos that set their own core.hooksPath (like this
one) bypass the global scan; they must scan themselves.
lib Shared functions (logging, template rendering, manifest)
The harness deploys an LLM-maintained wiki based on Andrej Karpathy's LLM Wiki pattern. The LLM builds and maintains structured Obsidian pages as you work; you never write the wiki yourself.
Setup: Set WIKI_VAULT in ~/.harness.env to your Obsidian vault path. The wiki module creates the structure on install.
How it works:
wiki/projects/<slug>/ with devlog, decisions/, plans/, spikes//wiki skill provides full operations: ingest external sources, query, lintWhat gets captured: plans, PRDs, architecture decisions (ADRs), exploration spikes, concepts, devlogs. Not captured: routine fixes, debugging transcripts, AI bloat.
Vault structure:
$WIKI_VAULT/
├── wiki/
│ ├── projects/<slug>/ ← per-project knowledge
│ ├── concepts/ ← cross-project concepts
│ ├── synthesis/ ← filed query answers
│ ├── sources/ ← external source summaries
│ ├── index.md ← master catalog
│ └── log.md ← activity timeline
├── raw/ ← immutable source copies
└── WIKI_SCHEMA.md ← conventions
Set in ~/.harness.env:
USER_EMAIL=you@example.com
GITHUB_USER=yourhandle
DATA_ROOT=/path/to/data
MCP_COGNITIVE_CACHE_PATH=cognitive-cache-mcp
HOME_DIR=/home/youruser
./install.sh # Full deploy (default)
./install.sh --only claude # Single module
./install.sh --force # Redeploy even if unchanged
./install.sh --dry-run # Preview without changes
./install.sh --no-memory # Skip second-brain and the wiki
./install.sh --override-settings # Replace ~/.claude/settings.json instead of merging into it
./install.sh status # Show managed file state
./install.sh uninstall # Restore all backups
~/.harness.env ← Personal variables (not committed)
~/.harness-manifest.json ← Deployed file checksums (auto-generated)
harness/
├── configs/
│ ├── claude-code/ ← Templates: settings, CLAUDE.md, .mcp.json, hooks, skills
│ ├── cursor/ ← Templates: rules, mcp.json
│ ├── codex/ ← Templates: config.toml, instructions
│ └── opencode/ ← Templates: AGENTS.md, opencode.jsonc (reference)
├── modules/ ← Deploy logic per tool (claude.sh, cursor.sh, etc.)
├── lib/ ← Shared: template rendering, manifest, backup, detect
├── tools/ ← Bundled tools (web-strip)
└── install.sh ← Entrypoint
install.sh sources all libs and modules~/.harness.env for template variablesconfigs/ source was renamed or deleted: an unchanged copy the harness created is deleted along with any folder it leaves empty, one that replaced a file you had is swapped back to your original, and an edited copy or one with no pre-existence record stays in place for status to flag~/.harness-manifest.json with file hashes# 1. Install
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash
# 2. Configure
cat > ~/.harness.env << 'VARS'
USER_EMAIL=you@example.com
GITHUB_USER=yourhandle
DATA_ROOT=/path/to/data
HOME_DIR=$HOME
VARS
# 3. Deploy
~/.harness/install.sh
# 4. Verify
claude mcp list # Should show 5 servers connected
claude CLI (for plugin/MCP registration; configs deploy without it)uv/uvx (for cognitive-cache, markitdown)hooks/register.tsx 268 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, Timer, TurnUsage } from 'claude-code'
3
4import {
5 agentAlerts,
6 agentMissToast,
7 agentSegment,
8 MINUTE,
9 MISSES_KEPT,
10 asBounds,
11 asMisses,
12 effectiveTtl,
13 gateDecision,
14 gateQuestion,
15 joinSegments,
16 missToast,
17 observeStep,
18 pruneTracks,
19 segmentFile,
20 segmentFor,
21} from './rules'
22
23const last = atom({ plugin: 'cache-guard', key: 'last' } as const, null)
24const misses = atom({ plugin: 'cache-guard', key: 'misses' } as const, [])
25const agents = atom({ plugin: 'cache-guard', key: 'agents' } as const, {})
26const listed = atom({ plugin: 'cache-guard', key: 'listed' } as const, [])
27const dismissed = atom({ plugin: 'cache-guard', key: 'dismissed' } as const, [])
28
29const SEND = 'Send anyway'
30const COMPACT = 'Compact first'
31const DRAFT = 'Keep as draft'
32const SEGMENT_EVERY_MS = 30_000
33
34type Config = { defaultTtlMs: number; agentTtlMs: number; gateUsd: number }
35
36async function ttlMs($: EngineInterface, config: Config): Promise<number> {
37 return effectiveTtl(asBounds(await $.store.get('ttl')), config.defaultTtlMs)
38}
39
40async function agentTtlMs($: EngineInterface, config: Config): Promise<number> {
41 return effectiveTtl(asBounds(await $.store.get('agentTtl')), config.agentTtlMs)
42}
43
44async function segmentPath($: EngineInterface, sessionId: string): Promise<string> {
45 const state = (await $.env.get('XDG_STATE_HOME')) || `${await $.env.get('HOME')}/.local/state`
46 return `${state}/harness/status/${sessionId}/cache-guard.json`
47}
48
49async function writeSegment($: EngineInterface, config: Config): Promise<void> {
50 const now = await $.clock.now()
51 const segment = joinSegments(
52 segmentFor(await read($, last), now, await ttlMs($, config)),
53 agentSegment(await read($, agents), await read($, listed), now, await agentTtlMs($, config)),
54 )
55 await $.fs.write(await segmentPath($, await $.session.id()), segmentFile(segment, await $.clock.now()))
56}
57
58async function showSegment($: EngineInterface, config: Config): Promise<void> {
59 try {
60 await writeSegment($, config)
61 } catch (err) {
62 $.ui.log(`cache-guard: could not write the status segment: ${String(err)}`, { to: 'debug' })
63 }
64}
65
66// rmdir fails harmlessly while other mods' files remain, because the session folder is shared.
67async function removeSegment($: EngineInterface, sessionId: string): Promise<void> {
68 const path = await segmentPath($, sessionId)
69 try {
70 await $.process.run(['rm', '-f', path])
71 await $.process.run(['rmdir', path.slice(0, path.lastIndexOf('/'))])
72 } catch (err) {
73 $.ui.log(`cache-guard: could not remove ${path} (${String(err)}), emptying it`, { to: 'debug' })
74 await $.fs.write(path, '')
75 }
76}
77
78function listedAgent(info: AgentInfo) {
79 return { id: info.id, type: info.type, description: info.description, status: info.status }
80}
81
82async function refreshAgents($: EngineInterface): Promise<void> {
83 const list = (await $.agent.list()).map(listedAgent)
84 const now = await $.clock.now()
85 await update($, listed, () => list)
86 await update($, agents, tracked => pruneTracks(tracked, now))
87}
88
89async function tick($: EngineInterface, config: Config): Promise<void> {
90 try {
91 await refreshAgents($)
92 } catch (err) {
93 $.ui.log(`cache-guard: could not list agents: ${String(err)}`, { to: 'debug' })
94 }
95 await showSegment($, config)
96}
97
98async function forget($: EngineInterface, config: Config): Promise<void> {
99 await update($, last, () => null)
100 await showSegment($, config)
101}
102
103async function observe($: EngineInterface, config: Config, startedAt: number, usage: TurnUsage): Promise<void> {
104 const seen = observeStep({
105 prev: await read($, last),
106 startedAt,
107 now: await $.clock.now(),
108 usage,
109 bounds: asBounds(await $.store.get('ttl')),
110 defaultTtlMs: config.defaultTtlMs,
111 })
112 await update($, last, () => seen.last)
113 await $.store.set('ttl', seen.bounds)
114 const miss = seen.miss
115 if (!miss) return
116 const kept = await update($, misses, list => [...list, miss].slice(-MISSES_KEPT))
117 await $.store.set('misses', kept)
118 $.ui.toast(missToast(miss))
119}
120
121async function observeAgent(
122 $: EngineInterface,
123 config: Config,
124 agentId: string,
125 startedAt: number,
126 usage: TurnUsage,
127): Promise<void> {
128 const prev = (await read($, agents))[agentId] ?? null
129 const seen = observeStep({
130 prev,
131 startedAt,
132 now: await $.clock.now(),
133 usage,
134 bounds: asBounds(await $.store.get('agentTtl')),
135 defaultTtlMs: config.agentTtlMs,
136 })
137 const track = { ...seen.last, firstAt: prev?.firstAt ?? startedAt }
138 await update($, agents, tracked => ({ ...tracked, [agentId]: track }))
139 await $.store.set('agentTtl', seen.bounds)
140 if (!seen.miss) return
141 const miss = { ...seen.miss, agentId }
142 const kept = await update($, misses, list => [...list, miss].slice(-MISSES_KEPT))
143 await $.store.set('misses', kept)
144 const type = (await $.agent.list()).find(a => a.id === agentId)?.type ?? 'subagent'
145 $.ui.toast(agentMissToast(type, miss))
146}
147
148async function compactThenRestore($: EngineInterface, config: Config, text: string): Promise<void> {
149 try {
150 const compacted = await $.session.compact()
151 if (compacted.messages) await forget($, config)
152 else $.ui.toast(`Compaction skipped: ${compacted.skip}`)
153 } catch (err) {
154 $.ui.toast(`Compaction failed: ${String(err)}`)
155 }
156 await $.prompt.fill({ text })
157}
158
159export const register: Register = (on, options) => {
160 const config: Config = {
161 defaultTtlMs: Number(options.ttlMinutes ?? 60) * MINUTE,
162 agentTtlMs: Number(options.agentTtlMinutes ?? 5) * MINUTE,
163 gateUsd: Number(options.gateUsd ?? 1),
164 }
165 let ticker: Timer | undefined
166
167 on('session.start', async ($, e, next) => {
168 const stored = asMisses(await $.store.get('misses'))
169 await update($, misses, () => stored)
170 await tick($, config)
171 ticker?.cancel()
172 ticker = $.clock.every(SEGMENT_EVERY_MS, () => void tick($, config))
173 return next(e)
174 })
175
176 on('session.end', async ($, e, next) => {
177 ticker?.cancel()
178 ticker = undefined
179 if (e.reason === 'clear') await update($, last, () => null)
180 await removeSegment($, e.sessionId)
181 return next(e)
182 })
183
184 // Per-step usage, because turn.complete sums every request of the turn and a
185 // cold first request would hide behind the warm reads of the steps after it.
186 on('turn.step', async function* ($, e, next) {
187 const startedAt = await $.clock.now()
188 const response = yield* next(e)
189 if (response.usage) {
190 try {
191 if (e.agentId === undefined) await observe($, config, startedAt, response.usage)
192 else await observeAgent($, config, e.agentId, startedAt, response.usage)
193 } catch (err) {
194 $.ui.log(`cache-guard: could not record step usage: ${String(err)}`, { to: 'debug' })
195 }
196 }
197 return response
198 })
199
200 on('turn.complete', async ($, e, next) => {
201 await tick($, config)
202 return next(e)
203 })
204
205 on('session.compact', async ($, e, next) => {
206 const result = await next(e)
207 if (e.agentId === undefined && e.trigger !== 'precompute' && result.messages) await forget($, config)
208 return result
209 }).catch(($, e, next) => next(e))
210
211 // Fails open because this is a cost warning: a broken guard must never eat a prompt.
212 on('prompt.submit', async ($, e, next) => {
213 // Attachments cannot be put back in the prompt box, so a prompt carrying them is never held.
214 if (e.origin.kind !== 'composer' || e.turnId !== undefined || e.attachments) return next(e)
215 const gate = gateDecision(await read($, last), await $.clock.now(), await ttlMs($, config), config.gateUsd)
216 if (!gate) return next(e)
217
218 const choice = await $.ui
219 .ask(gateQuestion(gate), { header: 'Cold cache', options: [SEND, COMPACT, DRAFT] })
220 .catch((err: unknown) => {
221 $.ui.log(`cache-guard: cold cache question unanswered (${String(err)}), sending`, { to: 'debug' })
222 return SEND
223 })
224
225 if (choice === COMPACT) {
226 // The host refuses a compaction under the prompt this hook is holding, so it runs once the prompt is dropped.
227 $.clock.after(0, () => void compactThenRestore($, config, e.text))
228 return { drop: 'Compacting first; your prompt will be back in the box.' }
229 }
230 if (choice === DRAFT) {
231 await $.prompt.fill({ text: e.text })
232 return { drop: 'Kept as a draft; the prompt cache is still cold.' }
233 }
234 return next(e)
235 }).catch(($, e, next) => next(e))
236
237 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
238 if (e.props.hasSurvey) return next(e)
239 const alerts = agentAlerts(
240 await read($, agents),
241 await read($, listed),
242 await $.clock.now(),
243 await agentTtlMs($, config),
244 await read($, dismissed),
245 )
246 if (alerts.rows.length === 0) return next(e)
247 const { Box, Button, Text } = $.ui.resolve(e)
248 return (
249 <Box flexDirection="column">
250 {alerts.rows.map(row => (
251 <Box key={row.key} flexDirection="row" flexWrap="wrap" columnGap={1}>
252 <Text color="warning">{row.text}</Text>
253 <Button
254 key={`dismiss-${row.key}`}
255 label="Dismiss"
256 dimColor
257 role="dismiss"
258 onPress={() => update($, dismissed, keys => [...keys, row.key])}
259 />
260 </Box>
261 ))}
262 {alerts.more > 0 && <Text dimColor>+{alerts.more} more</Text>}
263 {await next(e)}
264 </Box>
265 )
266 })
267}
268hooks/rules.ts 256 lines1import type { ModelUsage, TurnUsage } from 'claude-code'
2
3import type { AgentTrack, LastResponse, ListedAgent, Miss, TtlBounds } from '../types'
4
5export const MINUTE = 60_000
6export const MISSES_KEPT = 20
7const MILLION = 1_000_000
8const FIVE_MINUTES = 5 * MINUTE
9const MIN_MISS_TOKENS = 50_000
10const MIN_STATUS_CTX = 20_000
11const MIN_AGENT_ALERT_TOKENS = 50_000
12const LONG_AGENT_MS = 60 * MINUTE
13const MAX_ALERT_ROWS = 3
14const TRACK_KEEP_MS = 24 * 60 * MINUTE
15
16export type Prices = { input: number; write1h: number; write5m: number; read: number }
17
18type PriceRow = { prefix: string; prices: Prices; tier?: { above: number; factor: number } }
19
20const OPUS_5_5: PriceRow = { prefix: 'claude-opus-5-5', prices: { input: 4, write1h: 8, write5m: 5, read: 0.2 } }
21
22const PRICE_ROWS: readonly PriceRow[] = [
23 OPUS_5_5,
24 { prefix: 'claude-sonnet-5-5', prices: { input: 2, write1h: 4, write5m: 2.5, read: 0.2 } },
25 { prefix: 'claude-fable-5-1', prices: { input: 10, write1h: 20, write5m: 12.5, read: 0.25 } },
26 {
27 prefix: 'claude-haiku-5-5',
28 prices: { input: 0.1, write1h: 0.2, write5m: 0.125, read: 0.01 },
29 tier: { above: 100_000, factor: 5 },
30 },
31]
32
33// Bedrock and Vertex ids carry a region or vendor prefix, so matching starts at "claude-".
34export function priceFor(model: string, tokens: number): Prices {
35 const id = model.slice(Math.max(0, model.indexOf('claude-')))
36 const row = PRICE_ROWS.find(r => id.startsWith(r.prefix)) ?? OPUS_5_5
37 if (!row.tier || tokens <= row.tier.above) return row.prices
38 const { factor } = row.tier
39 const p = row.prices
40 return { input: p.input * factor, write1h: p.write1h * factor, write5m: p.write5m * factor, read: p.read * factor }
41}
42
43export function contextOf(usage: ModelUsage): number {
44 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
45}
46
47// A cache that lives longer than five minutes is the 1-hour cache, which writes at the higher rate.
48export function rewriteUsd(model: string, tokens: number, ttlMs: number): number {
49 const prices = priceFor(model, tokens)
50 return (tokens * (ttlMs > FIVE_MINUTES ? prices.write1h : prices.write5m)) / MILLION
51}
52
53export function compactUsd(model: string, tokens: number): number {
54 return (tokens * priceFor(model, tokens).input) / MILLION
55}
56
57export type StepKind = 'warm' | 'cold'
58
59export function classifyStep(prevCtx: number, usage: ModelUsage): StepKind | null {
60 if (prevCtx <= 0) return null
61 if (usage.cache_read_input_tokens >= 0.5 * prevCtx) return 'warm'
62 // A cold step may still read some cache, because the system and tools prefix can stay cached on its own, a large share in a subagent.
63 if (usage.cache_creation_input_tokens >= 0.5 * prevCtx) return 'cold'
64 return null
65}
66
67export const noBounds: TtlBounds = { warmMaxMs: null, coldMinMs: null }
68
69// Invariant: warmMaxMs < coldMinMs. An observation that breaks it (a plan or TTL change) replaces the bounds.
70export function learnTtl(bounds: TtlBounds, kind: StepKind, gapMs: number): TtlBounds {
71 if (kind === 'warm') {
72 if (bounds.coldMinMs !== null && gapMs >= bounds.coldMinMs) return { warmMaxMs: gapMs, coldMinMs: null }
73 return { ...bounds, warmMaxMs: Math.max(bounds.warmMaxMs ?? 0, gapMs) }
74 }
75 if (bounds.warmMaxMs !== null && gapMs <= bounds.warmMaxMs) return { warmMaxMs: null, coldMinMs: gapMs }
76 return { ...bounds, coldMinMs: Math.min(bounds.coldMinMs ?? gapMs, gapMs) }
77}
78
79export function effectiveTtl(bounds: TtlBounds, defaultMs: number): number {
80 let ttl = defaultMs
81 if (bounds.warmMaxMs !== null) ttl = Math.max(ttl, bounds.warmMaxMs)
82 if (bounds.coldMinMs !== null) ttl = Math.min(ttl, bounds.coldMinMs)
83 return ttl
84}
85
86function finiteOrNull(value: unknown): number | null {
87 return typeof value === 'number' && Number.isFinite(value) ? value : null
88}
89
90export function asBounds(stored: unknown): TtlBounds {
91 if (typeof stored !== 'object' || stored === null) return noBounds
92 const { warmMaxMs, coldMinMs } = stored as Record<string, unknown>
93 return { warmMaxMs: finiteOrNull(warmMaxMs), coldMinMs: finiteOrNull(coldMinMs) }
94}
95
96export function asMisses(stored: unknown): Miss[] {
97 if (!Array.isArray(stored)) return []
98 return stored
99 .filter((m): m is Miss => typeof m === 'object' && m !== null && typeof (m as Miss).tokens === 'number')
100 .slice(-MISSES_KEPT)
101}
102
103export type StepObservation = {
104 prev: LastResponse | null
105 startedAt: number
106 now: number
107 usage: TurnUsage
108 bounds: TtlBounds
109 defaultTtlMs: number
110}
111
112export type Observed = { last: LastResponse; bounds: TtlBounds; miss: Miss | null }
113
114export function observeStep(o: StepObservation): Observed {
115 const last = { at: o.now, ctx: contextOf(o.usage), model: o.usage.model }
116 const unchanged = { last, bounds: o.bounds, miss: null }
117 // A new model starts its own cache, so its first step says nothing about expiry.
118 if (!o.prev || o.prev.model !== o.usage.model) return unchanged
119 const gapMs = o.startedAt - o.prev.at
120 const kind = classifyStep(o.prev.ctx, o.usage)
121 // A cold step within minutes of the last response means the prefix changed (tools, system prompt), not expiry.
122 if (kind === null || (kind === 'cold' && gapMs < FIVE_MINUTES)) return unchanged
123 const bounds = learnTtl(o.bounds, kind, gapMs)
124 const tokens = o.usage.cache_creation_input_tokens
125 if (kind === 'warm' || tokens < MIN_MISS_TOKENS) return { last, bounds, miss: null }
126 const usd = rewriteUsd(o.usage.model, tokens, effectiveTtl(bounds, o.defaultTtlMs))
127 return { last, bounds, miss: { at: o.now, gapMs, tokens, usd, model: o.usage.model } }
128}
129
130export type Gate = { idleMs: number; tokens: number; rewriteUsd: number; compactUsd: number }
131
132export function gateDecision(last: LastResponse | null, now: number, ttlMs: number, gateUsd: number): Gate | null {
133 if (!last) return null
134 const idleMs = now - last.at
135 if (idleMs < ttlMs) return null
136 const rewrite = rewriteUsd(last.model, last.ctx, ttlMs)
137 if (rewrite < gateUsd) return null
138 return { idleMs, tokens: last.ctx, rewriteUsd: rewrite, compactUsd: compactUsd(last.model, last.ctx) }
139}
140
141export function formatTokens(tokens: number): string {
142 if (tokens >= MILLION) return `${(tokens / MILLION).toFixed(1)}M`
143 if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
144 return String(tokens)
145}
146
147export function formatUsd(usd: number): string {
148 return usd < 0.005 ? '<$0.01' : `$${usd.toFixed(2)}`
149}
150
151export function formatDuration(ms: number): string {
152 if (ms < MINUTE) return `${Math.max(0, Math.floor(ms / 1000))}s`
153 return `${Math.floor(ms / MINUTE)}m`
154}
155
156export type Tone = 'normal' | 'warn'
157export type Segment = { text: string; tone: Tone }
158
159export function segmentFor(last: LastResponse | null, now: number, ttlMs: number): Segment | null {
160 if (!last || last.ctx < MIN_STATUS_CTX) return null
161 const left = last.at + ttlMs - now
162 if (left > 0) return { text: `cache ${formatDuration(left)}`, tone: 'normal' }
163 return { text: `cache cold ≈ ${formatUsd(rewriteUsd(last.model, last.ctx, ttlMs))}`, tone: 'warn' }
164}
165
166// The format must match what statusline.sh reads; tests/test_statusline.sh pins that side.
167export function segmentFile(segment: Segment | null, at: number): string {
168 return segment ? JSON.stringify({ text: segment.text, tone: segment.tone, at }) : ''
169}
170
171export function gateQuestion(gate: Gate): string {
172 return (
173 `The prompt cache has likely expired (idle ${formatDuration(gate.idleMs)}). ` +
174 `Sending re-writes ~${formatTokens(gate.tokens)} tokens (≈ ${formatUsd(gate.rewriteUsd)}); ` +
175 `compacting first reads them once (≈ ${formatUsd(gate.compactUsd)}). What now?`
176 )
177}
178
179export function missToast(miss: Miss): string {
180 return (
181 `Cache went cold after ${formatDuration(miss.gapMs)}: ` +
182 `re-wrote ${formatTokens(miss.tokens)} tokens (≈ ${formatUsd(miss.usd)} at API prices)`
183 )
184}
185
186const ENDED: ReadonlySet<ListedAgent['status']> = new Set(['completed', 'failed', 'killed'])
187const RESTING: ReadonlySet<ListedAgent['status']> = new Set(['idle', 'waiting'])
188
189function isLive(agent: ListedAgent): boolean {
190 return !ENDED.has(agent.status)
191}
192
193function isCold(agent: ListedAgent, track: AgentTrack | undefined, now: number, ttlMs: number): track is AgentTrack {
194 return track !== undefined && RESTING.has(agent.status) && now - track.at >= ttlMs
195}
196
197export type AgentAlert = { key: string; text: string }
198
199// An idle alert is keyed by the agent's last response so that a dismissal lasts only until the agent works and rests again.
200export function agentAlerts(
201 agents: Readonly<Record<string, AgentTrack>>,
202 listed: readonly ListedAgent[],
203 now: number,
204 ttlMs: number,
205 dismissed: readonly string[],
206): { rows: AgentAlert[]; more: number } {
207 const alerts: AgentAlert[] = []
208 for (const agent of listed) {
209 const track = agents[agent.id]
210 if (!track || !isLive(agent)) continue
211 const label = `${agent.type} "${agent.description}"`
212 if (isCold(agent, track, now, ttlMs) && track.ctx >= MIN_AGENT_ALERT_TOKENS) {
213 const usd = formatUsd(rewriteUsd(track.model, track.ctx, ttlMs))
214 alerts.push({
215 key: `${agent.id}@${track.at}`,
216 text: `${label} idle ${formatDuration(now - track.at)} · resuming re-writes ${formatTokens(track.ctx)} ≈ ${usd}`,
217 })
218 } else if (now - track.firstAt >= LONG_AGENT_MS) {
219 alerts.push({ key: `${agent.id}:long`, text: `${label} has run ${formatDuration(now - track.firstAt)}` })
220 }
221 }
222 const open = alerts.filter(alert => !dismissed.includes(alert.key))
223 return { rows: open.slice(0, MAX_ALERT_ROWS), more: Math.max(0, open.length - MAX_ALERT_ROWS) }
224}
225
226export function agentSegment(
227 agents: Readonly<Record<string, AgentTrack>>,
228 listed: readonly ListedAgent[],
229 now: number,
230 ttlMs: number,
231): Segment | null {
232 const live = listed.filter(isLive)
233 if (live.length === 0) return null
234 const cold = live.filter(agent => isCold(agent, agents[agent.id], now, ttlMs)).length
235 if (cold === 0) return { text: `agents ${live.length}`, tone: 'normal' }
236 return { text: `agents ${live.length} · ${cold} cold`, tone: 'warn' }
237}
238
239// Tracks age out by time, not by listing, because ended agents leave $.agent.list within a minute yet can be resumed much later.
240export function pruneTracks(tracks: Readonly<Record<string, AgentTrack>>, now: number): Record<string, AgentTrack> {
241 return Object.fromEntries(Object.entries(tracks).filter(([, track]) => now - track.at < TRACK_KEEP_MS))
242}
243
244export function joinSegments(...segments: (Segment | null)[]): Segment | null {
245 const shown = segments.filter((s): s is Segment => s !== null)
246 if (shown.length === 0) return null
247 return { text: shown.map(s => s.text).join(' · '), tone: shown.some(s => s.tone === 'warn') ? 'warn' : 'normal' }
248}
249
250export function agentMissToast(type: string, miss: Miss): string {
251 return (
252 `Agent ${type} went cold after ${formatDuration(miss.gapMs)}: ` +
253 `re-wrote ${formatTokens(miss.tokens)} tokens (≈ ${formatUsd(miss.usd)})`
254 )
255}
256types/index.d.ts 44 lines1export type LastResponse = {
2 at: number
3 ctx: number
4 model: string
5}
6
7export type TtlBounds = {
8 warmMaxMs: number | null
9 coldMinMs: number | null
10}
11
12export type Miss = {
13 at: number
14 gapMs: number
15 tokens: number
16 usd: number
17 model: string
18 agentId?: string
19}
20
21export type AgentTrack = LastResponse & {
22 firstAt: number
23}
24
25export type ListedAgent = {
26 id: string
27 type: string
28 description: string
29 // AgentStatus spelled out, because the validator requires a contract with no imports.
30 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'cache-guard': {
36 last: LastResponse | null
37 misses: Miss[]
38 agents: Record<string, AgentTrack>
39 listed: ListedAgent[]
40 dismissed: string[]
41 }
42 }
43}
44