SLOPSHOPPER

cache-timer

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.

newspinnertimer
A shopper browsing a rack in a slop shop
README

nixconfig

If you land on this repo and want to configure a Mac with it, start here.

What this gives you

This repo installs and manages:

  • macOS defaults
  • Homebrew formulae and casks
  • shell setup
  • Git, GitHub CLI, ssh, Zed, Neovim, Ghostty, Karabiner, Pi, Codex, Claude, Mise, and more
  • app preferences for Droppy, Smallcast, Bartender, Keka, OpenLogi, and others
  • local models for Llama.app, downloaded on rebuild
  • development tooling such as Go tools and Cargo binaries

It is designed for Apple Silicon Macs but it might work on Intel Macs too (I have not tested it).

Quick start

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:

  1. ask you for the machine-specific values it cannot guess safely
  2. install Apple Command Line Tools if needed
  3. install Nix
  4. install Homebrew
  5. clone this repo locally
  6. create hosts/local.nix for your machine
  7. run darwin-rebuild switch

What it asks you

The script prompts for:

  • the local host/configuration name
  • your macOS username
  • your Git author name
  • your Git author email
  • an optional Git SSH signing key

Important note

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.

After the install

Some app sign-ins and permissions are still intentionally manual. See:

Updating later

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.

Manual setup path

If you do not want to run the bootstrap script, the manual flow is:

  1. install Apple Command Line Tools
  2. install Nix
  3. install Homebrew
  4. clone the repo
  5. create hosts/local.nix from hosts/local.nix.example
  6. run:
sudo nix --extra-experimental-features 'nix-command flakes' \
  run github:LnL7/nix-darwin/master#darwin-rebuild -- switch --flake "path:$PWD#<your-host-name>"

Host configuration

Machine-specific values live in one of these files:

  • hosts/shared.nix: tracked hosts already in the repo
  • hosts/local.nix: local machine config created by the bootstrap script
  • hosts/local.nix.example: template for manual setup

hosts/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.

Use your fork instead of this repo

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"

Want the implementation details?

The technical structure was moved out of the main README. See:

Source 1 files
hooks/register.js 86 lines
1// 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