Metadata-Version: 2.4
Name: ceiling-guard
Version: 0.5.6
Summary: See how much of your Claude Code, Codex or Grok subscription window is left, and get a handoff before a plan limit ends the run
Author: Ak2tx LLC
License: MIT License
        
        Copyright (c) 2026 Ak2tx LLC
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/ak2tx/ceiling-guard
Project-URL: Source, https://github.com/ak2tx/ceiling-guard
Project-URL: Issues, https://github.com/ak2tx/ceiling-guard/issues
Project-URL: Changelog, https://github.com/ak2tx/ceiling-guard/blob/main/CHANGELOG.md
Keywords: ai-agents,llm,claude,chatgpt,codex,grok,usage-limits,rate-limits,quota,plan-window,token-budget,watchdog,monitoring,observability,agent-loop,cost-control
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# Ceiling Guard

[![ci](https://github.com/ak2tx/ceiling-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/ak2tx/ceiling-guard/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/ceiling-guard.svg)](https://pypi.org/project/ceiling-guard/)

Don't lose another agent run to a plan limit.

Ceiling Guard shows how much of your Claude Code, Codex or Grok
subscription window is left, warns your agent before it runs out, and writes a
handoff for the next run. It reads usage the official CLIs already record on
your machine — no provider credentials, no proxy, no account access.

Codex and Grok usage appears immediately. Claude publishes its percentage only
during a non-interactive run, so wrap one Claude command to start tracking it.

Zero dependencies. Python 3.11+. Linux, macOS, Windows, or a container.

```bash
uv tool install ceiling-guard     # or, if you have it: pipx install ceiling-guard

cg quota                                    # read-only, nothing to configure
cg wrap -- claude -p "continue the migration"
```

**If you have neither `uv` nor `pipx`, use a venv — not `pip --user`.** On
Debian, Ubuntu and every other PEP 668 distribution, the system Python is
externally managed and `python3 -m pip install --user ceiling-guard` is refused
outright:

```bash
python3 -m venv ~/.venvs/cg && ~/.venvs/cg/bin/pip install ceiling-guard
~/.venvs/cg/bin/cg quota
```

Measured on Ubuntu 24.04.4, Python 3.12.3, no pip cache: **2.9 seconds** from no
install to a true reading, over three consecutive runs. The first run on a box
that had never fetched the package took **7.6 seconds**, which is the number a
stranger actually sees. This README previously recommended `pip --user` as the
no-pipx fallback and claimed eleven seconds for it; both documented paths failed
on a stock Ubuntu 24.04 box. The error is kept in `CHANGELOG.md` rather than
quietly corrected.

**Names, because there are three of them.** The project and the PyPI package
are **`ceiling-guard`**. The command is **`cg`**. The Python import is still
`continuity_guard`, and the state directory is still `~/.continuity-guard/` —
renaming either would orphan the config, sessions database and quota cache of
anyone already running it, so that churn waits for 0.6.0. The older PyPI name
`continuity-guard` stops at 0.5.3 and is not yanked; `ceiling-guard` is where
releases continue.

`cg wrap` runs your command and observes it; it terminates nothing unless you
separately arm the daemon. For `claude -p` it appends
`--output-format stream-json --verbose` and renders the answer back to plain
text, because that event stream is the only place Claude's percentage exists —
it is never written to disk. It says so on stderr rather than rewriting your
command line silently. Every other command is passed through as you typed it.

Put it where you already look, instead of remembering to ask:

```bash
cg statusline                      # -> cg 48%   (or "cg -" when it cannot see)

# bash/zsh prompt
PS1='$(cg statusline) '"$PS1"

# tmux
set -g status-right '#(cg statusline)'
```

`statusline` reads no config, contacts no daemon and always exits 0, so it
cannot break the prompt it is added to. It prints `-` rather than a stale
percentage: a number that was true an hour ago is the failure this tool
exists to prevent.

Or put it in the agent's path instead of your own. `skills/ceiling-guard/` is an
Agent Skill: copy it next to your agent's skills (for Claude Code,
`cp -r skills/ceiling-guard ~/.claude/skills/`) and the agent reads the window
before starting an overnight job, instead of you remembering to. It is a
preflight policy, not enforcement — it makes the agent look and interpret
correctly; it cannot stop a run already in flight, and `cg wrap` still does not
tell a child to save.

```
PROVIDER   WINDOW         USED  RESETS               AGE  STATUS
openai     primary       97.0%  Sat 11:19            2m   ok
grok       weekly        63.0%  Sat 23:21           23m   ok
anthropic  five_hour     94.0%  Sat 12:30            0m   ok     <- after one wrapped Claude job
           seven_day     73.0%  Mon 02:00            0m   ok
```

A browser-only subscription is not a user of this tool: the reading comes from
what a CLI writes locally, and a browser writes nothing here.

Those numbers come from files the official clients already write, and from
stream events passing through `cg wrap`. Each adapter was checked against the
provider's own usage screen; the working is in
[docs/quota-accuracy.md](docs/quota-accuracy.md).

## What you will actually see

Those three sentences are the claim. This table is the evidence for them, and
it belongs here rather than in a footnote.

| you | `cg quota` shows |
|---|---|
| use the `codex` or `grok` CLI | their windows immediately. Those clients write usage to disk themselves. |
| use the `claude` CLI | nothing, until you wrap one session |
| use claude.ai or ChatGPT in a browser | nothing, ever |

Runs hosted inside another vendor's client spend that vendor's pool, not your
provider's. Cursor is the common case: CG does not see that ceiling. The
measurement is in [docs/quota-accuracy.md](docs/quota-accuracy.md).

Claude is the awkward case. It does publish a percentage, but only as a
`rate_limit_event` inside its `stream-json` output, and it never writes that to
disk. Polling a file will not find it. The number has to be caught in flight:

```bash
cg wrap -- claude -p "whatever you were going to run anyway"
cg quota                           # the anthropic rows are there now
```

The substitution is only applied when the child is `claude`, run with `-p`,
and you have not chosen a format yourself. A wrapper script under another name
will not be recognised, and that is deliberate: guessing at what a binary is
would be worse than missing one.

`cg wrap` terminates nothing, and says so on its first line every time.

It is not a transparent sleeve like `timeout` or `nice`, and it should not
claim to be. For `claude -p` specifically it adds `--output-format stream-json
--verbose`, because that is the only form in which the percentage exists, then
prints the answer back as plain text so the run looks the same to you. It tells
you it did that. An explicit `--output-format` of your own wins, an interactive
`claude` with no `-p` is left alone, and no other program is ever rewritten.

Browser sessions leave no local record at all. If that is how you use your
subscription, this tool has nothing to read and you should stop here.

## Warn the job before the window closes

Reading the number is useful on its own. The reason it exists is what comes
next: telling a running job that it is nearly out of room, while there is still
room to do something about it.

```python
from continuity_guard import guard

# `overnight` asks for termination, but asking is not enough: the daemon is in
# shadow mode until an operator arms it, so this snippet observes and nothing
# else. Two separate keys, deliberately.
with guard("nightly-refactor", profile="overnight") as s:
    for step in agent.run():
        s.progress(step=step.name, tool=step.tool, args=step.args,
                   tokens=step.tokens, depth=step.depth)
        if s.should_wrap_up():          # cheap, non-blocking, False by default
            save_state(s.headroom())    # what to save is yours to decide
            break
```

Every signalled session also gets a handoff written for it, whether or not it
answers. That matters because the sessions most worth preserving are usually
the ones too wedged to preserve themselves. What that document does and does
not preserve is set out in
[what the handoff is and is not](#what-the-handoff-is-and-is-not).

## The watchdog, when you want it

Twelve detectors cover the agent failures that return HTTP 200 while nothing
useful happens: loops, oscillation, pace collapse, token burn, runaway
recursion, crash loops.

It ships observe-only. Nothing is terminated until you have watched it against
your own traffic and decided to arm it.

```bash
cg init && cg install              # scaffold config, run as a service
cg status                          # how close each session is to firing, now
```

The order of this page is the order we suggest you adopt it. Reading the quota
costs you nothing and needs no permission. The wrap-up signal costs one line in
your loop. Kill authority comes last, because it is the only part that can take
something away from you.

---

## Plan windows, and not losing a run to one

The reading is the easy half. This section is the mechanics: how the number is
kept honest, and what happens to a live session when the window runs low.

A stale reading is refused rather than trusted. Once, a Grok cache read 13.0%
while the account was actually at 63%, from a file five days old. That is the
most dangerous state a meter can be in, because it looks like knowledge. Any
snapshot older than an hour is marked `IGNORED` and cannot trigger anything,
and an unknown age counts as stale.

At `wrapup_at`, which defaults to 95% of the binding window, every live session
is told to save its state. A handoff is written for each of them whether or not
they respond. A wedged agent cannot act on the signal, and that is when the
record is worth the most.

```python
# `overnight` asks for termination, but asking is not enough: the daemon is in
# shadow mode until an operator arms it, so this snippet observes and nothing
# else. Two separate keys, deliberately.
with guard("nightly-refactor", profile="overnight") as s:
    for step in agent.run():
        s.progress(...)
        if s.should_wrap_up():          # cheap, non-blocking
            write_handoff(s.headroom()) # what to save is yours to decide
            break
```

The handoff Ceiling Guard writes itself is a record of what it observed:
steps, tool calls, timings, tokens, the plan window at the time. Not a summary
of intent; the agent did not write it. If your agent also acts on the signal
you get a better one on top.

Ceiling signals go to the three ceiling conditions (a plan window, a `budget`,
a `deadline`) and never to faults. Nothing is wrong with a session that is
merely running out; a wedged one cannot answer anyway.

### What the handoff is and is not

You always know what a run reached, including when the agent died without
cooperating. Enumerable progress survives: which items are done, which remain,
and the command that resumes past them. When the work is a list, and a great
deal of agent work is a list, that is most of what you needed. The next run
picks up without repeating itself.

What does not survive is the agent's understanding. The half-formed
hypothesis, the reason approach A was abandoned, the thing it had noticed but
not yet put into words: that lives in the model's context and goes when the
context goes. Writing it down is lossy, and the loss is invisible in the
result, because you cannot tell from a summary what is missing from it. This
is the context-compaction problem and nothing here solves it.

Call it a warm start rather than a continuation. A successor reads the handoff
and begins informed instead of blind. It does not resume mid-thought.

Two consequences are worth planning around.

Task shape decides how much this is worth to you. Enumerable, independent units
hand off well. Exploratory debugging hands off badly, because its value was
never in the enumeration; it was in the hypothesis space, which is the part
that does not survive.

Findings also have a shelf life. In one measured case, two of four findings in
a handoff had already been fixed a few hours later, and a third was wrong on
its own terms. A document describing a moving codebase decays. Handoffs carry
an observed-at stamp so a reader can judge for themselves, which is mitigation
rather than a fix.

---

## Shadow mode

The daemon ships observe-only. With `shadow = true` it evaluates every
contract and logs every action it would have taken, and kills nothing.

Run it that way against real workloads first, and measure your own
false-positive rate. Nobody should grant kill authority to a new watchdog
before they have seen that number, and shadow mode produces the only metric
worth having: N sessions observed, zero healthy terminations.

```bash
python3 -m continuity_guard.daemon -c config.toml            # config decides
python3 -m continuity_guard.daemon -c config.toml --shadow   # force observe-only
python3 -m continuity_guard.daemon -c config.toml --armed    # force enforcement
```

---

## Notifications

Every sink declares which levels and detectors it wants.

```toml
[[notifications]]
kind = "file"
path = "~/continuity-guard-alerts.log"
on   = ["warn", "hard", "dead"]

[[notifications]]
kind    = "exec"                         # event passed as CG_* env vars
command = "~/bin/cg-notify.sh"
on      = ["hard", "dead"]

[[notifications]]
kind = "webhook"
url  = "http://127.0.0.1:9000/cg"
on   = ["hard", "burn", "repeat"]
```

A broken sink is logged and swallowed. Notification failure must never stop the
daemon doing its actual job.

---

## Accuracy

`tests/bench_accuracy.py` drives the real detector code against a simulated
clock, which gets thousands of sessions in seconds where the realtime soak
manages about a dozen in ten minutes. The healthy workloads are adversarial by
design. Each one is a legitimate pattern chosen because it looks like a
specific fault.

| Healthy archetype | Looks like | Why it is legitimate |
|---|---|---|
| `polling` | `repeat` | re-reads the same status file forever, while progressing |
| `pipeline` | `cycle` | read → edit → test, a new file each pass |
| `flaky` | `errors` | 30% of tool calls genuinely fail (empty greps, 404s) |
| `bursty` | `decay` | fast bursts split by 4-minute thinking pauses |
| `rate_limited` | `decay` | periodic provider backoffs |
| `build` | `decay` | 22% of steps take 30–150s |
| `long_tool` | `silence` | single 20-minute test-suite runs |

Current numbers on the archetypes above (default profile):

```
overall any-level FP   0.0%      hard-level FP   0.0%
loop 100%  cycle 100%  stall 100%  burn 100%  errors 100%
depth 100%  decay 100%  budget 100%  cold_freeze 100%  cold_stall 100%
```

**That figure is not universal, and the qualifier is the honest part.** Three
workload shapes are known to produce false positives and are excluded from the
gate, measured and published rather than deleted
(`tests/bench_accuracy.py --known-fail`):

| shape | any-level FP | hard-level FP | detector |
|---|---|---|---|
| polls one endpoint forever | 100% | 0% | `repeat` |
| alternates between two queues | 100% | **100%** | `cycle` |
| fast setup, then long work | 100% | 0% | `decay` |

Together those three measure **any-level FP 25.0%, hard-level FP 8.33%, min
recall 92%** (`tests/bench_accuracy.py --known-fail`, which reports and exits
0 rather than gating).

The second is a real hard-level false positive on healthy work: an agent
doing `poll(queue-a)`, `poll(queue-b)` with `seq` advancing every frame — real,
reported progress — reaches a hard `cycle` verdict on the sixth frame. That is
what a queue worker, a CI watcher and a deploy monitor look like.

The default action for `repeat` and `cycle` is `notify`, so nothing is
terminated out of the box. **Under a terminating profile it would be.** If your
agent polls, set `repeat.action` and `cycle.action` to `notify` explicitly, or
raise their thresholds.

No fix is offered, because a healthy periodic poller and a pathological loop
emit identical frames indefinitely and no finite confirmation window separates
them. See `docs/quota-accuracy.md`. The corpus missed this for a long time
because the archetype named `polling` did not actually poll — its docstring
claimed it did.

### Against real traffic, not just the corpus

The numbers above come from an adversarial corpus: synthetic sessions with
known ground truth, which is what makes false-positive rates measurable at all.
That is the right instrument for tuning and the wrong one for the question
anyone actually asks, which is whether it will kill their work.

So it also runs in shadow mode against real agent traffic, on two hosts (a
Raspberry Pi 5 and an x86-64 NUC) across four provider paths: the `claude` CLI
under `cg wrap`, and Grok, OpenRouter and local models through `cg-proxy`.
Shadow mode records the verdict and takes no action, so every session is a free
observation of what an armed daemon would have done.

As of 2026-08-29, since the harness fix in `63d4bc8`:

| | observed | healthy | would have been terminated |
|---|---|---|---|
| real model sessions | 629 | 475 | **0** |
| including synthetic soak | — | 3,470 | **0** |

Seven distinct real models. The one healthy session ever flagged in this
pipeline (`real-1787852493-clean`, a Claude-Haiku repo-QA run) was a **harness**
defect, not a detector one: the test loop's regex did not capture tool *input*,
so every call hashed identically and `repeat` fired correctly on what it was
shown. It is annotated in the ledger rather than deleted, and `63d4bc8` fixed
the loop.

That table is a false-positive measurement, not a recall one. Faults in the real loop are injected against short sessions
that often end before a detector's window elapses, so the catch rate there says
nothing useful and is not quoted; recall comes from the corpus and the matrix,
where the ground truth is exact.

The honest summary is that in roughly 630 real sessions, across seven models and
four provider paths, an armed daemon would have terminated nothing healthy. The
run is still accumulating, so treat that as a floor rather than a final number.
It is also one operator's machines. It is not a substitute for measuring your
own traffic before you arm anything.

Three things that only showed up at these sample sizes, all now fixed:

**The decay hard level needed its own floor.** When a session's learned
baseline is small, `floor` dominates both decay levels and `warn_k`/`hard_k`
collapse to the same threshold, so any qualifying slow run fires straight to
`hard`. On build traffic that was a 1.2% hard-level false positive at
`--trials 200`+ (invisible at 60). `decay.hard_floor` (default 200s) is now an
absolute backstop above the slowest gap healthy heavy-tailed traffic
produces; warn keeps the relative `floor`. Recall unchanged.

**`silence` and `decay` need opposite statistics from the same data.** Decay asks
"has the typical pace collapsed?", which is a central quantile. Silence asks
"how long may this agent legitimately go quiet?", which is the upper tail. Sharing one baseline
made bursty workloads false-positive at 94%, because a median baseline calls the
pauses anomalies when the pauses *are* the workload.

**Before the first completed step there is no information at all.** Firing a
warning there is guessing, and it is how a 20-minute-per-call agent gets flagged
in its first three minutes. Warnings are now suppressed until a session has
produced one step; the hard ceiling still applies, widened.

## Read next

The front page stops here on purpose. Everything below was in it and is now
one click away, because none of it belongs before the install line.

| | |
|---|---|
| [Why a heartbeat is not enough](docs/why-a-heartbeat-is-not-enough.md) | what a liveness ping cannot see, and why there are twelve detectors |
| [Tuning](docs/tuning.md) | profiles, thresholds, adaptive baselines, and the sweeps behind the defaults |
| [Integrating](docs/integrating.md) | four paths, from zero-code wrapping to the Python API |
| [Safety properties](docs/safety-properties.md) | the invariants, and what each one prevents |
| [The socket is kill authority](docs/socket-authority.md) | the local threat model |
| [Test matrix](docs/test-matrix.md) | the 26 rows and what each proves |
| [Testing with agents](docs/testing-with-agents.md) | running the suite against real CLI agents |
| [Accuracy, in full](docs/quota-accuracy.md) | every measurement, including the withdrawn ones |

## Install as a service

```bash
sudo mkdir -p /opt/continuity-guard /etc/continuity-guard
sudo cp -r continuity_guard /opt/continuity-guard/
sudo cp config.example.toml /etc/continuity-guard/config.toml
# the unit keeps its old name: renaming it would make `cg install` enable a
# second daemon alongside one already running and holding the socket
sudo cp systemd/continuity-guard.service /etc/systemd/system/
sudo systemctl enable --now continuity-guard
systemctl status continuity-guard
```

The unit sets `Restart=always` and `OOMScoreAdjust=-500`: the init system is what
watches the watchdog, and the daemon must outlive the memory pressure that kills
what it watches.

---

## Status

v0.5.6, reference implementation. **27/27 matrix checks across 26 rows** — 18 realtime
checks, a protocol fuzz, a ceiling-signal check, an authority check, a
packaging check, an environment-knob check, and a wrapper output/signal
check — passing on both architectures on this commit: Raspberry Pi 5
(aarch64, Python 3.13.5) and an x86-64 host (Python 3.12.3). See
docs/platform-support.md.

Pure Python so the contract semantics and thresholds can be validated fast on
real workloads. The wire protocol (`continuity_guard/protocol.py`, plus
`sanitize()` as its acceptance rule) is the durable artifact; the native daemon
reimplements against it once the semantics are proven here.

See `docs/` for the engineering history and the measurement behind each
decision.

MIT. Ak2tx LLC.
