From 9284325a99a987de12d2762c27ef8561e86f9190 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 16 Oct 2026 14:24:41 -0400 Subject: [PATCH 1/4] ccstatusline: only rerun git when HEAD or the index changes With the default gitCacheTtlSeconds of 5, every refresh in every session re-runs four git processes (two of them full worktree stats, since ccstatusline forces fsmonitor off) every 5 seconds, even when nothing changed. A TTL of 0 means the cached result stays valid until .git/HEAD or .git/index changes. Tradeoff: an unstaged edit doesn't show in the +/- counts until something rewrites the index (a plain git status/diff/add does). Co-Authored-By: Claude Opus 5.5 --- config/ccstatusline/settings.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/ccstatusline/settings.json b/config/ccstatusline/settings.json index 990db1a..293cc7c 100644 --- a/config/ccstatusline/settings.json +++ b/config/ccstatusline/settings.json @@ -75,5 +75,6 @@ ], "theme": "onedark", "autoAlign": false - } -} \ No newline at end of file + }, + "gitCacheTtlSeconds": 0 +} From e307d92e251a1ba3f903a85cc5aded0e8203d65c Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 16 Oct 2026 14:31:36 -0400 Subject: [PATCH 2/4] docs: ADR 0061 on keeping the statusline cheap Covers the pin, the mise install + shim (#66), and the HEAD/index-keyed git cache, with the per-refresh breakdown and the Rust statusline benchmarks that were considered. Co-Authored-By: Claude Opus 5.5 --- doc/adr/0061-hot-path-statusline-cost.md | 80 ++++++++++++++++++++++++ doc/adr/README.md | 1 + 2 files changed, 81 insertions(+) create mode 100644 doc/adr/0061-hot-path-statusline-cost.md diff --git a/doc/adr/0061-hot-path-statusline-cost.md b/doc/adr/0061-hot-path-statusline-cost.md new file mode 100644 index 0000000..06a4e6b --- /dev/null +++ b/doc/adr/0061-hot-path-statusline-cost.md @@ -0,0 +1,80 @@ +# 61. Keep the Claude Code statusline cheap: mise install, no npx, HEAD/index-keyed git cache + +Date: 2026-10-16 + +## Status + +Accepted + +## Context + +The statusline command in `claude/roles/base.jsonc` reruns every time a Claude Code session's conversation updates, in every session. That's easy to forget, because one run is cheap. With 15-20 agent sessions open at once, though, `npm exec ccstatusline` showed up in most `ps` snapshots taken during two load-spike investigations (2026-10-12 and 2026-10-16), at 20-80% CPU and 70-110MB RSS each. + +It started as `npx -y ccstatusline@latest`. Tracing one refresh in a large repo (pickleton, ~6,600 tracked files) showed where the time goes: + +- **npm startup.** `npx` starts Node, resolves the package, then starts a second Node for ccstatusline itself. Pinning the version (`ccstatusline@2.2.32`, PR #58) skips the "latest" registry lookup but not the npm startup. With a warm npm cache, pinned and `@latest` benchmarked the same: ~1.2s wall and ~0.7s CPU per run. +- **Node startup for ccstatusline itself:** ~120ms user CPU on every run. +- **Child processes on a git-cache miss.** The `git-branch` and `git-changes` widgets run `git rev-parse`, `git symbolic-ref HEAD`, `git diff --shortstat` and `git diff --cached --shortstat`, and terminal-width detection runs `ps`. Every spawn pays the endpoint-security exec scan (CrowdStrike Falcon on work machines), so even a trivial `git rev-parse` cost ~100ms wall for ~19ms of CPU. +- **Full worktree stats.** ccstatusline passes `-c core.fsmonitor=` to every git call (so it never starts fsmonitor daemons), which means each `git diff` stats every tracked file: ~200ms of kernel time per call, with every file access also going through the endpoint-security hooks. + +ccstatusline caches git output on disk (`~/.cache/ccstatusline/git-cache`), keyed by the mtimes of `.git/HEAD` and `.git/index`. By default (`gitCacheTtlSeconds: 5`) an entry also expires after 5 seconds, so an unchanged repo still reruns all four git calls every 5 seconds per session. + +All of this work is process spawns and syscalls, the two things that get slowest when the machine is already loaded, so the statusline added the most load exactly when there was least to spare. + +## Decision + +Three changes, each addressing a different layer: + +1. **Pin the version.** `ccstatusline@2.2.32` instead of `@latest`, so the statusline doesn't upgrade itself between sessions. This is a reproducibility fix; it made no measurable difference to cost. +2. **Install it with mise and call it by name.** `"npm:ccstatusline" = "2.2.32"` lives in `config/mise/conf.d/dotfiles.toml`, and the statusline command is bare `ccstatusline`, resolved through the mise shim the same way the `codebase-memory-mcp` hooks are. This drops npm from the hot path. User CPU per run went from ~320ms to ~135ms, and the shim added ~5% wall time over calling the binary directly. (PR #66) +3. **Set `gitCacheTtlSeconds: 0`** in `config/ccstatusline/settings.json`. Zero doesn't disable the cache. It keeps a cached entry valid until `.git/HEAD` or `.git/index` changes, so an idle repo makes no git calls at all. In pickleton, a warm-cache run is ~150ms with no child processes, against ~690ms for a cold one. (PR #67) + +The general rule this follows: anything on a hot path (the statusline, hooks, anything run per tool call) shouldn't go through `npx`/`uvx`, and should avoid spawning processes it can cache. + +### Alternatives Considered + +1. **Keep `npx`, just pinned** + - Pros: no install step; nothing to keep in sync across machines. + - Cons: npm's startup is the cost, and pinning doesn't remove it. + - Rejected: measured no improvement over `@latest`. + +2. **Switch to a Rust statusline.** Benchmarked in pickleton, 30 runs each, load average ~11: + + | Tool | Wall | User CPU | Sys CPU | Child processes | + | ----------------------------------------------------------------------------------------------------- | ------ | -------- | ------- | --------------- | + | ccstatusline, warm git cache | ~150ms | ~120ms | ~20ms | none | + | ccstatusline, cold git cache | ~690ms | ~170ms | ~260ms | 4 git + 1 ps | + | [statusline-rs-cc](https://github.com/Darkwing4/statusline-rs-cc) | ~280ms | ~20ms | ~300ms | 1 `git status` | + | [claude-code-statusline-pro](https://github.com/Wangnov/claude-code-statusline-pro) (`show_dirty` on) | ~290ms | ~30ms | ~200ms | none (libgit2) | + - Pros: almost no user CPU, few or no child processes. claude-code-statusline-pro does git in-process through libgit2. + - Cons: the worktree walk is the kernel-time cost no matter which tool does it, and neither keeps a git cache across runs (claude-code-statusline-pro's cache is in-process, so it dies after every refresh). claude-code-statusline-pro's much faster default numbers come from `show_dirty` defaulting to off, which skips the dirty check entirely. + - Rejected for now: ccstatusline with a warm cache does less kernel work per refresh. Worth revisiting if Node's ~120ms user CPU per refresh becomes the bottleneck, or if one of them adds a persistent cache. + +3. **`gitCacheTtlSeconds` of 30-60** (60 is the maximum) + - Pros: the `+/-` counts are never more than a minute stale. + - Cons: still reruns git once a minute per session for repos that haven't changed. + - Rejected: I rarely look at the counts. Slightly stale is better than paying for them constantly. + +4. **Drop the `git-changes` widget** + - Pros: removes the two worktree-stat diffs entirely. + - Cons: loses the counts. With TTL 0 they're nearly free anyway. + +## Consequences + +### Positive + +- An idle session's statusline costs one Node startup, with no npm, git, or `ps` processes. +- The version only changes when it's bumped in `dotfiles.toml`. +- The per-refresh cost is written down, so the next "why is the statusline in my `ps` output" doesn't start from zero. + +### Negative + +- **Rollout order matters.** On each machine: pull, `mise install`, then `./claudeconfig.sh`. If `claudeconfig.sh` runs first, the statusline shows "command not found" until mise has installed it. +- **The `+/-` counts can lag.** An unstaged edit doesn't show up until something rewrites `.git/index`. A plain `git status`, `diff`, or `add` does that. Tools that run git with `--no-optional-locks` (or `GIT_OPTIONAL_LOCKS=0`) don't, and neither does editing a file without touching git. +- **`config/ccstatusline/settings.json` is still a hand-copied twin** of `~/.config/ccstatusline/settings.json`, not a symlink (see the comment in `.config/prettier/ignore`). Changes have to be copied by hand in whichever direction they were made. +- **Other `npx …@latest` calls remain:** `bin/claude-status-line` (claude-powerline) and `bin/ccusage-refresh` ([ADR 0023](0023-ccusage-for-spend-tracking.md)). Neither is on the per-refresh path today. + +## Related + +- [ADR 0013](0013-claude-code-configuration-management.md): how `claude/roles/*.jsonc` becomes `~/.claude/settings.json` +- [ADR 0017](0017-claude-spend-tmux-status.md): the switch to ccstatusline diff --git a/doc/adr/README.md b/doc/adr/README.md index 55da816..4ff743f 100644 --- a/doc/adr/README.md +++ b/doc/adr/README.md @@ -59,3 +59,4 @@ - [58. work-coder-role](0058-work-coder-role.md) - [59. automic-vault-for-git-signing-and-agent-ssh](0059-automic-vault-for-git-signing-and-agent-ssh.md) - [60. agent-safehouse-wrapper](0060-agent-safehouse-wrapper.md) +- [61. hot-path-statusline-cost](0061-hot-path-statusline-cost.md) From 6d0a4d1710e89e8fa4c9843e7da39a37f962a6ca Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 16 Oct 2026 14:35:48 -0400 Subject: [PATCH 3/4] docs: ADR 0061, describe the exec scan generically Co-Authored-By: Claude Opus 5.5 --- doc/adr/0061-hot-path-statusline-cost.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/adr/0061-hot-path-statusline-cost.md b/doc/adr/0061-hot-path-statusline-cost.md index 06a4e6b..bfdb6cb 100644 --- a/doc/adr/0061-hot-path-statusline-cost.md +++ b/doc/adr/0061-hot-path-statusline-cost.md @@ -14,7 +14,7 @@ It started as `npx -y ccstatusline@latest`. Tracing one refresh in a large repo - **npm startup.** `npx` starts Node, resolves the package, then starts a second Node for ccstatusline itself. Pinning the version (`ccstatusline@2.2.32`, PR #58) skips the "latest" registry lookup but not the npm startup. With a warm npm cache, pinned and `@latest` benchmarked the same: ~1.2s wall and ~0.7s CPU per run. - **Node startup for ccstatusline itself:** ~120ms user CPU on every run. -- **Child processes on a git-cache miss.** The `git-branch` and `git-changes` widgets run `git rev-parse`, `git symbolic-ref HEAD`, `git diff --shortstat` and `git diff --cached --shortstat`, and terminal-width detection runs `ps`. Every spawn pays the endpoint-security exec scan (CrowdStrike Falcon on work machines), so even a trivial `git rev-parse` cost ~100ms wall for ~19ms of CPU. +- **Child processes on a git-cache miss.** The `git-branch` and `git-changes` widgets run `git rev-parse`, `git symbolic-ref HEAD`, `git diff --shortstat` and `git diff --cached --shortstat`, and terminal-width detection runs `ps`. Every spawn pays endpoint security software's exec scan, so even a trivial `git rev-parse` cost ~100ms wall for ~19ms of CPU. - **Full worktree stats.** ccstatusline passes `-c core.fsmonitor=` to every git call (so it never starts fsmonitor daemons), which means each `git diff` stats every tracked file: ~200ms of kernel time per call, with every file access also going through the endpoint-security hooks. ccstatusline caches git output on disk (`~/.cache/ccstatusline/git-cache`), keyed by the mtimes of `.git/HEAD` and `.git/index`. By default (`gitCacheTtlSeconds: 5`) an entry also expires after 5 seconds, so an unchanged repo still reruns all four git calls every 5 seconds per session. From 73024b66b3246868b7d83a91692184aa0bf2264e Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 16 Oct 2026 14:38:21 -0400 Subject: [PATCH 4/4] docs: format ADR 0061 with the repo's prettier Co-Authored-By: Claude Opus 5.5 --- doc/adr/0061-hot-path-statusline-cost.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/adr/0061-hot-path-statusline-cost.md b/doc/adr/0061-hot-path-statusline-cost.md index bfdb6cb..8e33270 100644 --- a/doc/adr/0061-hot-path-statusline-cost.md +++ b/doc/adr/0061-hot-path-statusline-cost.md @@ -34,6 +34,7 @@ The general rule this follows: anything on a hot path (the statusline, hooks, an ### Alternatives Considered 1. **Keep `npx`, just pinned** + - Pros: no install step; nothing to keep in sync across machines. - Cons: npm's startup is the cost, and pinning doesn't remove it. - Rejected: measured no improvement over `@latest`. @@ -46,11 +47,13 @@ The general rule this follows: anything on a hot path (the statusline, hooks, an | ccstatusline, cold git cache | ~690ms | ~170ms | ~260ms | 4 git + 1 ps | | [statusline-rs-cc](https://github.com/Darkwing4/statusline-rs-cc) | ~280ms | ~20ms | ~300ms | 1 `git status` | | [claude-code-statusline-pro](https://github.com/Wangnov/claude-code-statusline-pro) (`show_dirty` on) | ~290ms | ~30ms | ~200ms | none (libgit2) | + - Pros: almost no user CPU, few or no child processes. claude-code-statusline-pro does git in-process through libgit2. - Cons: the worktree walk is the kernel-time cost no matter which tool does it, and neither keeps a git cache across runs (claude-code-statusline-pro's cache is in-process, so it dies after every refresh). claude-code-statusline-pro's much faster default numbers come from `show_dirty` defaulting to off, which skips the dirty check entirely. - Rejected for now: ccstatusline with a warm cache does less kernel work per refresh. Worth revisiting if Node's ~120ms user CPU per refresh becomes the bottleneck, or if one of them adds a persistent cache. 3. **`gitCacheTtlSeconds` of 30-60** (60 is the maximum) + - Pros: the `+/-` counts are never more than a minute stale. - Cons: still reruns git once a minute per session for repos that haven't changed. - Rejected: I rarely look at the counts. Slightly stale is better than paying for them constantly.