Counts down the prompt cache's lifetime in the prompt footer, so you can tell whether your next message will be a cheap cache read or a full re-send.

If you land on this repo and want to configure a Mac with it, start here.
This repo installs and manages:
rebuildIt is designed for Apple Silicon Macs but it might work on Intel Macs too (I have not tested it).
Paste this into a fresh macOS terminal:
tmp="$(mktemp -t nixconfig-bootstrap.XXXXXX.sh)" &&
curl -fsSL https://raw.githubusercontent.com/arthur-fontaine/nixconfig/main/scripts/bootstrap-macos.sh -o "$tmp" &&
chmod +x "$tmp" &&
"$tmp"
The bootstrap script will:
hosts/local.nix for your machinedarwin-rebuild switchThe script prompts for:
If Apple Command Line Tools are not already installed, macOS may open a system dialog during bootstrap. Approve it, let it finish, then return to the terminal and continue.
Some app sign-ins and permissions are still intentionally manual. See:
Once the machine is bootstrapped:
cd ~/nixconfig
sudo darwin-rebuild switch --flake "path:$PWD#<your-host-name>"
If you installed the repo somewhere else, replace ~/nixconfig with your chosen path. Use the path: prefix as long as your machine definition lives in git-ignored hosts/local.nix.
If you do not want to run the bootstrap script, the manual flow is:
hosts/local.nix from hosts/local.nix.examplesudo nix --extra-experimental-features 'nix-command flakes' \
run github:LnL7/nix-darwin/master#darwin-rebuild -- switch --flake "path:$PWD#<your-host-name>"
Machine-specific values live in one of these files:
hosts/shared.nix: tracked hosts already in the repohosts/local.nix: local machine config created by the bootstrap scripthosts/local.nix.example: template for manual setuphosts/local.nix is git-ignored on purpose, so you can bootstrap a new machine without editing tracked files first. If you want to version your host definition, move it into hosts/shared.nix after the first successful install.
If you fork this repository, you can still use the same bootstrap script while pointing it at your fork:
tmp="$(mktemp -t nixconfig-bootstrap.XXXXXX.sh)" &&
curl -fsSL https://raw.githubusercontent.com/arthur-fontaine/nixconfig/main/scripts/bootstrap-macos.sh -o "$tmp" &&
chmod +x "$tmp" &&
NIXCONFIG_REPO_URL=https://github.com/<you>/nixconfig.git "$tmp"
The technical structure was moved out of the main README. See:
docs/repository-layout.mddocs/syncing-config.md: how to copy a setting you changed in an app back into the repohooks/register.js 86 lines1// cache-timer
2//
3// Adds `cache 59:49` to the mode labels in the prompt footer: the time left
4// before the session's prompt cache expires. Once it hits zero the label reads
5// `cache cold`, meaning the next message re-sends the whole context at full
6// price.
7//
8// The clock restarts on every main-thread request, and on every keep-alive
9// ping cache-warmer sends. Pings are read from cache-warmer's state rather
10// than by hooking `model.fork`, which would only see them if this mod happened
11// to run before cache-warmer.
12//
13// The label is hidden while a turn runs: each request it sends restarts the
14// clock, so a countdown would only flicker near 60:00.
15
16let ttlMs = 60 * 60_000
17let touchedAt = 0
18// turn.start doesn't say whose turn it is, so subagent turns are dropped once
19// one of their steps names its agent.
20const running = new Set()
21
22function pad(n) {
23 return String(n).padStart(2, '0')
24}
25
26function countdown(ms) {
27 const s = Math.ceil(ms / 1000)
28 return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`
29}
30
31export function register(on, options) {
32 if (typeof options.ttlMinutes === 'number' && options.ttlMinutes > 0) {
33 ttlMs = options.ttlMinutes * 60_000
34 }
35
36 on('session.start', async ($, e, next) => {
37 // Not cancelled on session.end: /clear ends a session without starting the
38 // mod again, so the next conversation needs the same timer.
39 $.clock.every(1000, () => $.ui.invalidate('ui.render'))
40 return next(e)
41 })
42
43 // Subagents send a different prefix, so their requests leave the main cache
44 // as it was.
45 on('turn.start', async ($, e, next) => {
46 running.add(e.turnId)
47 $.ui.invalidate('ui.render')
48 return next(e)
49 })
50
51 on('turn.complete', async ($, e, next) => {
52 running.delete(e.turnId)
53 $.ui.invalidate('ui.render')
54 return next(e)
55 })
56
57 on('turn.step', async function* ($, e, next) {
58 if (e.agentId !== undefined) {
59 running.delete(e.turnId)
60 return yield* next(e)
61 }
62 // The cache's lifetime starts when the request is sent, not when its
63 // response finishes streaming.
64 const sentAt = await $.clock.now()
65 const result = yield* next(e)
66 if (result.usage) touchedAt = sentAt
67 return result
68 })
69
70 on('session.end', async ($, e, next) => {
71 touchedAt = 0
72 running.clear()
73 return next(e)
74 })
75
76 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
77 if (running.size > 0) return next(e)
78 const { value: warm } = await $.state.get({ plugin: 'cache-warmer', key: 'warm' })
79 const last = Math.max(touchedAt, warm?.lastPingAt ?? 0)
80 if (last === 0) return next(e)
81 const left = last + ttlMs - (await $.clock.now())
82 const label = left > 0 ? `cache ${countdown(left)}` : 'cache cold'
83 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
84 })
85}
86