From 93bfb456b861c4d7092a462cfe991a9363d9bef1 Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:44:46 +0300 Subject: [PATCH 01/10] Bump __version__ to 2.1.0 It was left at 2.0.0 when 2.1 was committed. --- src/cache_timer/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cache_timer/__init__.py b/src/cache_timer/__init__.py index 027a68b..ce8230e 100644 --- a/src/cache_timer/__init__.py +++ b/src/cache_timer/__init__.py @@ -1,3 +1,3 @@ """A Claude Code status line segment counting down the prompt cache.""" -__version__ = "2.0.0" +__version__ = "2.1.0" From f22db4cc7b1db97e2868c8d5991aa96b50cb10e6 Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:44:46 +0300 Subject: [PATCH 02/10] Fix two tests that could not pass off Linux One compared a literal POSIX path against a value that goes through os.path.abspath; the other compared --abbrev-ref HEAD against git_branch, which cannot match on a detached HEAD. --- tests/test_cache_timer.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_cache_timer.py b/tests/test_cache_timer.py index 86a0ff7..e344f66 100644 --- a/tests/test_cache_timer.py +++ b/tests/test_cache_timer.py @@ -432,7 +432,24 @@ def test_real_repository(self): self.skipTest("git is not installed") if probe.returncode != 0: self.skipTest("git could not resolve HEAD") - self.assertEqual(timer.git_branch(ROOT), probe.stdout.strip()) + expected = probe.stdout.strip() + if expected == "HEAD": + # A detached HEAD, which is how CI checks out a pull request. + # `--abbrev-ref` answers the literal string "HEAD" there, while + # git_branch reports the short SHA, so resolve the SHA to compare + # against. + resolved = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding="utf-8", + errors="replace", + ) + if resolved.returncode != 0: + self.skipTest("git could not resolve HEAD") + expected = resolved.stdout.strip()[:7] + self.assertEqual(timer.git_branch(ROOT), expected) class TestSubprocess(TranscriptCase): @@ -581,7 +598,9 @@ def test_bare_name_when_the_console_script_is_on_path(self): chosen = install.candidate() self.assertEqual(chosen.command(), install.CONSOLE_SCRIPT) # settings.json gets the bare name; verification runs the resolved path. - self.assertEqual(chosen.argv[0], "/usr/bin/cct") + # Through abspath, because that is what `candidate` applies, and on + # Windows it rewrites this POSIX path to C:\usr\bin\cct. + self.assertEqual(chosen.argv[0], os.path.abspath("/usr/bin/cct")) def test_ascii_flag_is_appended(self): self.patch("shutil", FakeShutil(which={install.CONSOLE_SCRIPT: "/usr/bin/cct"})) @@ -645,6 +664,10 @@ def test_verify_decodes_the_glyphs_off_a_utf8_locale(self): Run in a child interpreter because the encoding is resolved from the locale at interpreter start and cannot be patched afterwards. + + Reproduced through a POSIX locale. Windows takes its preferred encoding + from the active code page, which these variables do not set, so there + the test passes without exercising the case. """ script = ( "import sys; sys.path.insert(0, %r);" @@ -679,6 +702,9 @@ def test_verify_decodes_the_glyphs_off_a_utf8_locale(self): self.assertTrue(result.stdout.startswith("(True,"), result.stdout) def test_verify_rejects_a_silent_command(self): + # `true` exists only on Unix. Windows reaches the same result through the + # unrunnable-command branch, so the empty-output branch is covered only + # off Windows. ok, _ = install.verify(install.Candidate(["true"], ["true"])) self.assertFalse(ok) From 4d3fe636ea2d86df0322bbc9e7405d0ab8cd57f9 Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:44:46 +0300 Subject: [PATCH 03/10] Ignore .claude/settings.local.json It was only hidden by a global ignore rule. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7ead860..4e2ed08 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ venv/ build/ dist/ *.egg-info/ + +# Per-machine Claude Code permissions. A shared .claude/settings.json stays trackable. +.claude/settings.local.json From d4538ef07aa8fe4490b116d465df7575178fe46e Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:44:46 +0300 Subject: [PATCH 04/10] Add author, project URLs and Python version classifiers --- pyproject.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bce4bea..012dd6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,17 +10,30 @@ license = "GPL-3.0-or-later" requires-python = ">=3.8" dynamic = ["version"] dependencies = [] +authors = [{ name = "Gabriel Preda" }] keywords = ["claude", "claude-code", "statusline", "prompt-cache"] +# No "License ::" classifier: `license` above is an SPDX expression, and PEP 639 +# says a project uses one form or the other, never both. classifiers = [ + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Utilities", ] [project.urls] Homepage = "https://github.com/GabrielAndreiPreda/claude-code-cache-timer" +Source = "https://github.com/GabrielAndreiPreda/claude-code-cache-timer" +Issues = "https://github.com/GabrielAndreiPreda/claude-code-cache-timer/issues" +Changelog = "https://github.com/GabrielAndreiPreda/claude-code-cache-timer/blob/main/CHANGELOG.md" # The status line command in settings.json is this bare name. A name has no path # separators, no spaces and nothing any shell treats specially, so it runs the From 7f0b834c44e0e62ba0b6f68e86bbdca8b6e52a9b Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:45:25 +0300 Subject: [PATCH 05/10] Add CI for Linux, macOS and Windows Python 3.8 runs on a pinned ubuntu-22.04 runner because setup-python has no 3.8 build for the 24.04 image. --- .github/workflows/tests.yml | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..591b7e2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,64 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# The status line has no runtime dependencies and CI needs nothing from the API. +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + # Report every platform, rather than stopping at the first failure. + fail-fast: false + matrix: + include: + # setup-python has no 3.8 build for the 24.04 image ubuntu-latest now + # resolves to, so the floor declared in pyproject.toml is tested on a + # pinned 22.04 runner. + - { os: ubuntu-22.04, python: "3.8" } + - { os: ubuntu-latest, python: "3.9" } + - { os: ubuntu-latest, python: "3.11" } + - { os: ubuntu-latest, python: "3.13" } + # Windows covers the code page and path separator differences. + - { os: windows-latest, python: "3.8" } + - { os: windows-latest, python: "3.13" } + - { os: macos-latest, python: "3.13" } + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + + - name: Run tests + run: python -m unittest discover -s tests -v + + build: + name: Build the package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Build wheel and sdist + run: | + python -m pip install --upgrade build twine + python -m build + # Catches metadata that is valid TOML but invalid packaging. + python -m twine check dist/* + + - name: Install the wheel and run the console script + run: | + python -m pip install dist/*.whl + claude-cache-timer --version + claude-cache-timer --help From 76521041343d50e825984cc0ce86c7522f331bb7 Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:45:25 +0300 Subject: [PATCH 06/10] Add a contributing guide, issue templates and a PR template --- .github/ISSUE_TEMPLATE/bug_report.yml | 89 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 12 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 29 +++++++ .github/pull_request_template.md | 9 +++ CONTRIBUTING.md | 84 ++++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a47e3bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,89 @@ +name: Bug report +description: The status line shows something wrong, or the installer failed. +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened + description: What the status line showed, or what the installer printed. + placeholder: The countdown showed 58:12 on a session I had left idle overnight. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What you expected instead + validations: + required: true + + - type: input + id: os + attributes: + label: Operating system + description: > + Include whether you are in WSL. WSL and Windows have separate home + directories and need separate installs. + placeholder: Windows 11, running Claude Code inside WSL2 (Ubuntu 24.04) + validations: + required: true + + - type: input + id: python + attributes: + label: Python version + description: Output of `python3 --version`. + placeholder: "3.11.9" + validations: + required: true + + - type: input + id: claude-code + attributes: + label: Claude Code version + description: Output of `claude --version`. + placeholder: "2.1.220" + validations: + required: true + + - type: dropdown + id: install-method + attributes: + label: How you installed it + options: + - uv tool install + - pipx install + - from a clone, without installing + - other (say which below) + validations: + required: true + + - type: textarea + id: settings + attributes: + label: Your statusLine block + description: > + The `statusLine` key from `~/.claude/settings.json`. Paste the block + only, not the whole file, which may hold API keys or other settings you + would rather not publish. + render: json + placeholder: | + "statusLine": { + "type": "command", + "command": "claude-cache-timer", + "refreshInterval": 1 + } + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Anything else + description: > + Your terminal emulator, if the problem is glyphs or colour. Relevant + transcript lines, if the countdown itself is wrong. Redact those first: + transcripts contain your conversation. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..537d55f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +blank_issues_enabled: false +contact_links: + - name: How the timer knows what it knows + url: https://github.com/GabrielAndreiPreda/claude-code-cache-timer/blob/main/CACHE-MECHANISM.md + about: > + Where the number comes from, why the clock is the last assistant turn, + and why there are no hooks. + - name: Known limitations + url: https://github.com/GabrielAndreiPreda/claude-code-cache-timer/blob/main/README.md#limitations + about: > + The status line being hidden during permission prompts, and the countdown + reading slightly optimistic, are both already documented. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..77dd7e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest something the status line should do differently. +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to work out that the display does not tell you + description: Describe the situation rather than the feature. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you have in mind + validations: + required: false + + - type: textarea + id: extra + attributes: + label: Anything else + description: > + The command runs once per second in every open session, so cost on the + render path is worth mentioning if your suggestion affects it. See + CONTRIBUTING.md. + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..72507d6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## What this changes + + + +## Checks + +- [ ] `python3 -m unittest discover -s tests` passes locally +- [ ] Nothing new on the render path can raise or write to stderr +- [ ] Comments still match the decisions they document diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..631f052 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +Bug reports and pull requests are welcome. + +## Two constraints to know first + +The status line runs once a second in every open session, so anything added to the render +path is paid for on every tick. This is why the branch is read out of `.git/HEAD` rather +than by running `git`, and why `install.py` is imported inside the subcommand branch of +`cli.py` rather than at the top. + +It also may not raise or write to stderr. Claude Code renders whatever the command prints, +so a traceback becomes the status line and a non-zero exit becomes a blank row. Every entry +point swallows exceptions and returns 0. If a value cannot be computed, print less rather +than an error. + +## Running it + +The package has no dependencies and the tests import from the checkout, so there is nothing +to install. + +```sh +git clone https://github.com/GabrielAndreiPreda/claude-code-cache-timer +cd claude-code-cache-timer + +python3 -m unittest discover -s tests +``` + +To run the status line by hand, feed it the payload Claude Code would. Any real transcript +under `~/.claude/projects/` works; use one from a recent session, or the countdown will +read cold. + +```sh +echo '{"transcript_path":"'$HOME'/.claude/projects//.jsonl", + "model":{"display_name":"Opus"}, + "workspace":{"current_dir":"'$PWD'"}}' \ + | PYTHONPATH=src python3 -m cache_timer +``` + +To exercise the installer without changing your own configuration: + +```sh +PYTHONPATH=src python3 -m cache_timer install --dry-run +``` + +`--dry-run` verifies the command and prints what it would write. Without it, the installer +backs `~/.claude/settings.json` up to `~/.claude/backups/` before changing anything. + +## Style + +No runtime dependencies. + +Use `%` formatting rather than f-strings. `requires-python` is `>=3.8` and CI tests that +floor, so newer syntax fails there. + +Comments explain why rather than what. If you change a decision one of them documents, +update the comment with it. + +Tests go in `tests/test_cache_timer.py`. They cover cases a live session will not produce on +demand: a 5-minute cache, a record too large for the tail window, a session idle for days, +and a transcript touched long after its last API call. + +## Pull requests + +Run the tests first. CI runs them again on Linux, macOS and Windows across Python 3.8 +through 3.13, which catches assumptions about POSIX paths and locale encodings. + +Describe what the change does and why. If it changes what the status line displays, include +a before and after of the row. + +## Reporting a bug + +Open an issue. The template asks for your OS, Python version, Claude Code version, install +method, and the `statusLine` block from `~/.claude/settings.json`. + +If the displayed number looks wrong, include the output of `--version` and, if you can, the +last few lines of the transcript it was reading. Redact them first: transcripts contain your +conversation. + +## How it works + +[CACHE-MECHANISM.md](CACHE-MECHANISM.md) documents the mechanism in full, including why the +clock is the last assistant turn rather than the newest record or the file's mtime, and why +there are no hooks. It is worth reading before changing `statusline.py`. From 58239658252e75f4251a0270b8a3f81aad9cf3fa Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:45:25 +0300 Subject: [PATCH 07/10] Add a changelog covering 1.0.0 to 2.1.0 --- CHANGELOG.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d04365 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +Notable changes to this project. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[semantic versioning](https://semver.org/spec/v2.0.0.html). + +Releases before 2.1.0 were tagged retroactively, so their dates come from the commits. + +## [Unreleased] + +Nothing yet. + +## [2.1.0] - 2026-08-12 + +The encoding fix below was committed on 2026-08-10 but never released: `__version__` was +left at `2.0.0`, so no package reported itself as 2.1.0. This release is the first that +does. + +### Fixed + +- Install verification reported a working command as broken on Windows. It read the child + process's output using the system's preferred encoding, which on a legacy code page + cannot represent the hourglass. The failure happened inside a reader thread, so it + neither propagated nor filled the buffer, and verification saw empty output. The reader + now decodes UTF-8 explicitly, matching what the status line writes. +- `__version__` still read `2.0.0`. +- A test compared `candidate().argv[0]` against a literal POSIX path, but that value goes + through `os.path.abspath`, which rewrites it on Windows. The test could not pass there. +- `test_real_repository` compared `git rev-parse --abbrev-ref HEAD` against `git_branch`, + which cannot hold on a detached HEAD, where the former answers the literal string `HEAD` + and the latter answers a short SHA. It now resolves the SHA, so the detached case is + tested against real git rather than only a fixture. +- The README documented a status line wrapping mode that was removed in 2.0.0. +- `.claude/settings.local.json` is now ignored by the repository rather than relying on a + contributor's global git configuration. + +### Added + +- Continuous integration on Linux, macOS and Windows across Python 3.8 through 3.13, plus a + job that builds the package, checks its metadata, installs the wheel and runs the console + script. +- `CONTRIBUTING.md`, issue and pull request templates, and this changelog. +- Package metadata: author, issue and changelog URLs, and per-version Python classifiers. +- A `--version` flag entry in the README options table, and a pinned install form, since + installing from `main` gave no way to hold a version. + +### Changed + +- The README is reorganised so the install command is reachable without scrolling, and the + longer explanations of PATH, shell quoting and `settings.json` parsing moved into + collapsible sections. +- `image.png` is now `docs/statusline.png`. + +## [2.0.0] - 2026-08-10 + +### Changed + +- Installation moved to `uv tool install` or `pipx install`, and the package gained a + `claude-cache-timer` console script. The status line command written into + `settings.json` is now that bare name. +- Shell detection is gone. A bare name has no path separators, no spaces and nothing any + shell treats specially, so it runs identically whether Claude Code routes the status + line through Git Bash, PowerShell or `sh`, and the installer never has to work out + which. +- The code moved to a `src/cache_timer/` package with separate `cli`, `statusline` and + `install` modules. `install.py` and `uninstall.py` at the repository root were replaced + by `claude-cache-timer install` and `claude-cache-timer uninstall`. + +### Removed + +- Status line wrapping. Earlier versions kept an existing status line and appended the + countdown to its output, which meant running the wrapped command under `/bin/sh` or + `cmd.exe`, often not the shell it was written for. The installer now stops when it finds + a status line it did not write, and `--force` replaces it. + +## [1.0.0] - 2026-08-07 + +### Added + +- Initial release. A status line segment counting down the session's prompt cache, reading + both the remaining time and the cache TTL out of the session transcript, with no hooks + and no state file. + +[Unreleased]: https://github.com/GabrielAndreiPreda/claude-code-cache-timer/compare/v2.1.0...HEAD +[2.1.0]: https://github.com/GabrielAndreiPreda/claude-code-cache-timer/compare/v2.0.0...v2.1.0 +[2.0.0]: https://github.com/GabrielAndreiPreda/claude-code-cache-timer/compare/v1.0.0...v2.0.0 +[1.0.0]: https://github.com/GabrielAndreiPreda/claude-code-cache-timer/releases/tag/v1.0.0 From be0909a8356891b87ee6157cde64e7fbbf75ae55 Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:45:25 +0300 Subject: [PATCH 08/10] Tidy CACHE-MECHANISM.md formatting Trailing whitespace, double blank lines, and two stray sentences under Reading the TTL. --- CACHE-MECHANISM.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/CACHE-MECHANISM.md b/CACHE-MECHANISM.md index d31e39d..0c16858 100644 --- a/CACHE-MECHANISM.md +++ b/CACHE-MECHANISM.md @@ -50,7 +50,7 @@ request went out. A slash command is the clearest case: running `/exit` or `/clear` appends a timestamped record and makes no request at all, so a clock keyed to the newest record restarts on a -cache nothing touched. Your own turn is the same problem in slower motion — it is +cache nothing touched. Your own turn is the same problem in slower motion: it is written when you hit enter, before the request it will eventually trigger. The marker that does hold is `message.usage`. Only assistant turns carry one, and only @@ -85,9 +85,9 @@ find the TTL, and one backwards walk answers both questions. This compares all three clocks across every transcript you have. A row where mtime disagrees by hours is a session an mtime countdown would have lied about; a non-zero last column is a session the newest-record countdown would have lied about, by that -many seconds of cache already spent. Sessions that ended on a local record — which is -common, since local records are written after the last response — are the ones to look -at. +many seconds of cache already spent. The rows to look at are the sessions that ended on a +local record, which is the common case, since local records are written after the last +response. ```sh python3 - <<'EOF' @@ -136,24 +136,21 @@ The filesystem layout makes this self-solving: Subagent writes land in a subdirectory, so they add no record to the parent file. Read the parent and the stall is already reflected. - ## Reading the TTL - Assistant records carry the answer under `message.usage.cache_creation`: ```json "cache_creation": { "ephemeral_1h_input_tokens": 19627, "ephemeral_5m_input_tokens": 0 } ``` -Whichever bucket is non-zero is the TTL that was written. Three details matter: +Whichever bucket is non-zero is the TTL that was written. Three details matter. A turn that only reads the cache records `{0, 0}` in both buckets. Those records are not an answer and have to be skipped, not treated as "unknown". -Sessions can move between TTLs mid-run. -Re-read the value every tick rather than caching it once at startup. - +Sessions can move between TTLs mid-run, so the value has to be re-read every tick rather +than cached once at startup. If no non-zero bucket exists anywhere in the tail, the TTL is genuinely unknown and the display says so. One exception: past 3600 seconds of inactivity the session is cold no @@ -180,10 +177,10 @@ addition to the event-driven updates". The docs recommend it for exactly this ca based data, or a main session sitting idle while background subagents work. Without `refreshInterval` the command runs only when a new assistant message arrives, when -`/compact` finishes, when the permission mode changes, and when vim mode toggles. +`/compact` finishes, when the permission mode changes, and when vim mode toggles. -The status line is hidden during permission dialogs, autocomplete, and the help menu. -This cannot be changed from inside the status line. +The status line is hidden during permission dialogs, autocomplete, and the help menu, and +nothing inside the status line can change that. ## Why there are no hooks @@ -199,7 +196,6 @@ terminal emulator, returning 0. Every session then matches every other session. tmux, in containers, and over SSH the same thing happens for different reasons. Any cleanup logic keyed on PID deletes live sessions' state. - ## Why this is not a plugin Plugins can ship skills, agents, hooks, MCP servers, LSP servers, and monitors. A plugin @@ -220,6 +216,4 @@ noise. Against five minutes on a slow turn it is worth knowing. The countdown rounds up, so the display reads `0:01` through the final second rather than sitting at `0:00` while the cache is still alive. -During permission prompts the number is invisible, as described -above. - +During permission prompts the number is invisible, as described above. From 87722923aed65c0b564d8b04354ef89287988679 Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:45:25 +0300 Subject: [PATCH 09/10] Restructure the README Install is reachable without scrolling, the long PATH and settings.json notes moved into collapsible sections, and image.png became docs/statusline.png. --- README.md | 127 ++++++++++++++++++++----------- image.png => docs/statusline.png | Bin 2 files changed, 81 insertions(+), 46 deletions(-) rename image.png => docs/statusline.png (100%) diff --git a/README.md b/README.md index 2a75fa8..1e38ce3 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,22 @@ # claude-code-cache-timer -A Claude Code status line segment that counts down the time left on your session's -prompt cache. +[![Tests](https://github.com/GabrielAndreiPreda/claude-code-cache-timer/actions/workflows/tests.yml/badge.svg)](https://github.com/GabrielAndreiPreda/claude-code-cache-timer/actions/workflows/tests.yml) +[![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)](pyproject.toml) +[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) +[![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](pyproject.toml) +A Claude Code status line segment that counts down the time left on your session's prompt +cache. -![Status line display](image.png) +![The status line row: model, directory and branch in dim grey, then a green hourglass reading 56:44 on a 1h cache](docs/statusline.png) +## Why -When the cache lapses, the next turn re-writes the entire prompt prefix at 1.25x -(5-minute cache) or 2x (1-hour cache) the base input rate. Nothing in the UI tells you -how much time is left, so you can't tell whether to keep typing now or go make coffee. +When the cache lapses, the next turn re-writes the entire prompt prefix at 1.25x (5-minute +cache) or 2x (1-hour cache) the base input rate. Nothing in the UI tells you how much time +is left, so you can't tell whether to keep typing now or go make coffee. + +## What you see | Display | Meaning | |---|---| @@ -21,8 +28,7 @@ how much time is left, so you can't tell whether to keep typing now or go make c Configuring any status line replaces Claude Code's built-in one, so the model, directory and branch it used to show would otherwise vanish. They are redrawn here, dimmed, ahead of -the countdown. If you are wrapping an existing status line, that command already draws the -row and the prefix is left off. +the countdown. The branch is read straight out of `.git/HEAD` rather than by running `git`, which costs 7 µs instead of 780 µs. Linked worktrees and submodules, whose `.git` is a pointer file @@ -33,42 +39,27 @@ rather than a directory, are followed; a detached HEAD shows a short SHA. Requires Python 3.8 or newer. Linux, macOS, and Windows. No dependencies. ```sh -uv tool install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer -# or: pipx install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer +uv tool install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer@v2.1.0 +# or: pipx install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer@v2.1.0 claude-cache-timer install ``` -Then open a new Claude Code session. To preview without writing anything, use -`--dry-run`. - -If the shell cannot find `claude-cache-timer` after the first command, run `uv tool -update-shell` (or `pipx ensurepath`) and open a new terminal before the second. The -installer needs the name on PATH and stops with that same advice if it is missing, -without touching `settings.json`. +Then open a new Claude Code session. -The status line command it writes is just that name. A name has no path separators, no -spaces and nothing any shell treats specially, so it runs the same whether Claude Code -routes the status line through Git Bash, PowerShell or `sh`, and none of them has to be -identified first. That is why the installer insists on the name rather than falling back -to an absolute path, which would have to be quoted for a shell it cannot inspect. - -WSL and Windows are separate environments with separate home directories, so a Claude Code -you run in each needs its own install. +Drop the `@v2.1.0` to track `main` instead of pinning. To preview the change without +writing anything, run `claude-cache-timer install --dry-run`. The installer adds a `statusLine` entry to `~/.claude/settings.json` and copies the old file into `~/.claude/backups/` first. It changes nothing else and leaves your hooks and -permissions alone. - -Claude Code allows comments and trailing commas in `settings.json`, which Python's JSON -parser rejects. If your file has either, the installer stops without touching it and -prints the block to paste in yourself, rather than rewriting the file and stripping your -comments. +permissions alone. Before writing, it runs the command it is about to install against a +synthetic payload, so a broken command fails loudly at install time instead of leaving you +a silently blank status line. Only one status line can be active at a time. If you already have one, the installer stops and shows it rather than discarding it; pass `--force` to replace it. -Options: +### Options | Flag | Effect | |---|---| @@ -76,6 +67,9 @@ Options: | `--interval N` | seconds between refreshes (default 1) | | `--dry-run` | verify and print the change without writing | | `--force` | replace an existing status line that is not this one | +| `--version` | print the installed version | + +### Uninstall ```sh claude-cache-timer uninstall @@ -99,9 +93,9 @@ call the session makes, including the many that no hook reports. It deliberately is not the last *record*. Most of what a transcript logs is local, and much of it is timestamped, so a clock keyed to the newest record of any kind restarts on things that cost nothing: run `/exit` or `/clear` and the countdown jumps back to full -on a cache that is still draining. The file's own mtime is wrong the same way, and -worse — Claude Code touches transcripts long after a session's last call, so an idle -session reports most of an hour left on a cache that went cold days ago. +on a cache that is still draining. The file's own mtime is wrong the same way, and worse. +Claude Code touches transcripts long after a session's last call, so an idle session +reports most of an hour left on a cache that went cold days ago. Subagents come out right for free. They write to a `subagents/` subdirectory, and their calls do not refresh the parent's cache. So while a subagent runs, the parent transcript @@ -147,23 +141,64 @@ slower. Raise `refreshInterval` to `2` in `settings.json` if you would rather. The status line also requires that you have accepted the workspace trust dialog, the same gate that applies to hooks. -## Tests +## Troubleshooting + +
+The shell cannot find claude-cache-timer + +Run `uv tool update-shell` (or `pipx ensurepath`) and open a new terminal, then run +`claude-cache-timer install` again. The install put the command somewhere your PATH does +not cover. + +The installer needs the name on PATH and stops with that same advice if it is missing, +without touching `settings.json`. + +
+ +
+Why the installed command is a bare name and not a path + +The status line command written into `settings.json` is just `claude-cache-timer`. A name +has no path separators, no spaces and nothing any shell treats specially, so it runs the +same whether Claude Code routes the status line through Git Bash, PowerShell or `sh`, and +none of them has to be identified first. + +That is why the installer insists on the name rather than falling back to an absolute +path, which would have to be quoted for a shell it cannot inspect. + +
+ +
+It works in Windows but not WSL, or the other way round + +WSL and Windows are separate environments with separate home directories, so a Claude Code +you run in each needs its own install. + +
+ +
+The installer refuses to touch my settings.json + +Claude Code allows comments and trailing commas in `settings.json`, which Python's JSON +parser rejects. If your file has either, the installer stops without touching it and +prints the block to paste in yourself, rather than rewriting the file and stripping your +comments. + +
+ +## Contributing ```sh python3 -m unittest discover -s tests ``` -78 tests covering TTL detection (both buckets, mid-session changes, records too large for -the tail window), the clock (including a transcript touched long after its last call, and -the local records that must not reset it), the colour bands, graceful degradation on -every malformed input, branch -reading (worktrees, submodules, detached HEAD), the install and uninstall round trip, and -the command forms. +The suite covers TTL detection, the clock, the colour bands, branch reading, the install +and uninstall round trip, and degradation on malformed input. CI runs it on Linux, macOS +and Windows across Python 3.8 through 3.13. -Whichever form it settles on, the installer runs it against a synthetic payload before -writing anything, so a broken command fails loudly at install time instead of leaving you -a silently blank status line. +See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and +[CACHE-MECHANISM.md](CACHE-MECHANISM.md) for how the mechanism works. ## License -GNU GPL v3.0 +GNU GPL v3.0. See [LICENSE](LICENSE). diff --git a/image.png b/docs/statusline.png similarity index 100% rename from image.png rename to docs/statusline.png From cb285b7f03fb1aa87035a6b8463d2da18871f09c Mon Sep 17 00:00:00 2001 From: Gabriel Preda Date: Wed, 12 Aug 2026 12:55:29 +0300 Subject: [PATCH 10/10] Do not name a specific tag in the install command The README would have to be edited at every release, and it pointed at a tag that did not exist yet. --- CHANGELOG.md | 4 ++-- README.md | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d04365..dbe25b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,8 @@ does. script. - `CONTRIBUTING.md`, issue and pull request templates, and this changelog. - Package metadata: author, issue and changelog URLs, and per-version Python classifiers. -- A `--version` flag entry in the README options table, and a pinned install form, since - installing from `main` gave no way to hold a version. +- A `--version` flag entry in the README options table, and instructions for pinning an + install to a tag, since installing from `main` gave no way to hold a version. ### Changed diff --git a/README.md b/README.md index 1e38ce3..362cf57 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,17 @@ rather than a directory, are followed; a detached HEAD shows a short SHA. Requires Python 3.8 or newer. Linux, macOS, and Windows. No dependencies. ```sh -uv tool install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer@v2.1.0 -# or: pipx install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer@v2.1.0 +uv tool install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer +# or: pipx install git+https://github.com/GabrielAndreiPreda/claude-code-cache-timer claude-cache-timer install ``` Then open a new Claude Code session. -Drop the `@v2.1.0` to track `main` instead of pinning. To preview the change without -writing anything, run `claude-cache-timer install --dry-run`. +To pin a release rather than track `main`, append `@` and a tag from the +[releases page](https://github.com/GabrielAndreiPreda/claude-code-cache-timer/releases). +To preview the change without writing anything, run `claude-cache-timer install --dry-run`. The installer adds a `statusLine` entry to `~/.claude/settings.json` and copies the old file into `~/.claude/backups/` first. It changes nothing else and leaves your hooks and